Repository navigation
Commit bbd6072
fix(console): save a language picked on the sign-in page as the person's own when they have none (#30539)
<!-- claude-projects-attribution: {"github_login":"os-sales"} -->
_Requested by **Sales** · [project
thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuWaHFkA9vyxuQvdUXUZnrDs)_
domain:ui#4 · os-sales · session_01L6bhgVfPeFZkN1FP93imKU
## 维护者速读
-
改了什么:在登录页选了语言、本人还没设过语言偏好的,登录时这个选择会存成本人的语言偏好(`sys_user.locale`),换设备、发邮件都跟着走。
- 为什么改:#30338 只做到「选的语言在这台浏览器上留住」,没有存进个人偏好;登录页靠整页跳转离开,跳转前没人写库。
- 风险与回滚:只有登录页选过语言才多发两次请求(读一次本人记录、写一次),已有偏好不改,写失败照常登录。回滚即撤本 PR。
- 先看:`LoginPage.tsx` Post-login effect 新增的第 0 步;`useUserLocale.ts` 的
`saveSignInLanguagePick`。
- 两分钟验证:退出登录,在登录页底部的地球图标里选 Français,用一个没设过语言的账号登录,进个人资料页,语言应为
Français;刷新仍是法语。
- 需要你做的:无,席位落地。
Fixes #30399
Clause-②: yes (widening)
Metadata-protocol: none
## What changes
- `@object-ui/app-shell` `useUserLocale.ts`: a language picked while
signed out is now also held for one save (`signInPickToSave`, page-load
memory, no storage key). New `saveSignInLanguagePick(userId, writer?)`
reads the person's `sys_user` row and, only when `locale` is unset,
writes the pick through the column's one write door, `update('sys_user',
id, { locale })`, the call `useLanguageSelection` makes. It spends the
pick on the first call, sends no request when nothing was picked, and
never rejects: a failed read or write logs once at `error` (the pick
stays in the device slot, so this browser still renders it).
`hasSignInLanguagePick()` answers whether there is anything to save.
- Barrel: `hasSignInLanguagePick`, `saveSignInLanguagePick` and the
`SignInLanguagePickWriter` type are exported from `@object-ui/app-shell`
(hence `Clause-②: yes (widening)`; the claim's line says `no` and names
this case for the seat to correct).
- `LoginPage.tsx`, the Post-login orchestration effect only: a new step
0 starts the save once (ref latch) when there is a pick, holds the
spinner, and re-runs the effect when the save settles (state). Steps 1
to 3 are unchanged, so all five exits wait on it:
| Exit | Applies | Why |
|---|---|---|
| OAuth provider hand-off (`followOauthAuthorize`) | yes | a full
navigation to the relying party; same person, same page-load |
| Mobile sign-in hand-off (`assign(handoff.target)`) | yes | full
navigation; a session the hand-off refuses for its age is held to the
form and saves nothing (it is not a sign-in yet) |
| No organization (`assign(root or redirect)`) | yes | full navigation |
| Several organizations (`assign('/organizations')`) | yes | full
navigation |
| Final redirect (`assign(redirect or root)`, after the single-org
auto-select) | yes | full navigation; the auto-select itself is not an
exit |
The "Read public auth config" effect and the `DevAdminRow` render are
untouched (#30420 is changing them in parallel).
## Why
Measured on `origin/main` `f6faf751` and in a live sign-in (Verification
below): the pick ends in the `objectui-locale` slot, `sys_user.locale`
stays empty. Two findings the order's suggested route needed adjusting
for:
- **No adapter on the sign-in page.** The order suggested awaiting
`adapter.update('sys_user', id, { locale })`. `useAdapter()` answers
`null` on `/login`: the console mounts `AdapterProvider` inside
`ConnectedShell`, and the public auth routes render outside it. So
`saveSignInLanguagePick` builds the same `ObjectStackAdapter`
`AdapterProvider` builds (same base URL, `createAuthenticatedFetch`) for
the one save. Live, that is one `GET /api/v1/discovery`, one `GET` and
one `PATCH` on `/api/v1/data/sys_user/ID`, all before the next
page-load's discovery.
- **The owner-change keeper spends its copy first.** #30338's
`signedOutPick` is consumed by the owner-change event, which fires
(inside `AuthProvider`) before the post-login effect runs, and never
fires when the browser's last owner signs in again. So the save reads a
second page-load copy, cleared by the same signed-in-switch path.
Route B as ruled: awaited before the navigation, no fire-and-forget, no
new storage marker.
## Acceptance notes
- **Bound, unchanged from #30338:** memory lasts one page-load. A pick
followed by a reload of the sign-in page, or by an identity provider
round-trip, is not saved.
- **No timeout on the save.** A request that hangs keeps the spinner up
as long as the adapter's own fetch does; a refused or failed request
lets the sign-in proceed. Noted, not added: a bound would be a choice of
number nobody has ruled on.
- **Same shape, reported, not fixed (order):** `RegisterPage.tsx`
renders the same footer language menu and leaves by the same four
`window.location.assign` exits after a sign-up that signs the person in,
so a pick made there is kept in the slot but not saved. `SetupPage.tsx`
has no language menu of its own; a pick made on another signed-out page
earlier in the same page-load would reach its two exits unsaved.
- **Observed, not this card's:** with no pick, the existing post-login
effect can navigate twice in the test harness (once before the
organizations answer, once after). The new pins assert only that the
page leaves and saves nothing in that case.
- An existing language is kept even when the deployment does not offer
it (live row 2: a stored `de` stays `de`); what renders then is the
existing reader's business, unchanged here.
## Tests
-
`ui/packages/app-shell/src/hooks/__tests__/userLocaleSignInPick-30338.test.tsx`,
new top-level describe (8 cases): saved for a new owner; saved when the
last owner signs in again (no owner change); an existing language is
read and kept; CONTROL no pick, no request; spent once; a signed-in
switch replaces it; a failed write resolves, logs at `error`, slot kept;
a failed read writes nothing.
-
`ui/apps/console/src/pages/auth/__tests__/signInLanguagePick-30399.test.tsx`
(new, 11 cases): for each of the five exits, nothing leaves while the
save is in flight and the expected exit is taken once it settles;
CONTROL for each exit with no pick; a session the mobile hand-off
refuses saves nothing.
<details><summary>vitest: locale hook files, the app-shell layout suite
and every console auth page test (merged head 3e162ec)</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/ apps/console/src/pages/auth/
Test Files 172 passed (172)
Tests 1180 passed (1180)
os-verify-lock: VERDICT command-exit 0
```
</details>
<details><summary>Ablations (each through scripts/ablation-replace.mjs,
mutation proven on disk, restored: blob == HEAD, git diff HEAD
empty)</summary>
```
A1 LoginPage: drop the step-0 return (the save starts, the exit runs beside it = route C)
signInLanguagePick-30399: 5 failed | 6 passed (11) — every exit case red, every CONTROL green
A2 useUserLocale: drop the "column already set" check
userLocaleSignInPick-30338: 1 failed | 12 passed (13) — "a language of their own already saved is kept"
A3 useUserLocale: do not hold the pick for the save
userLocaleSignInPick-30338: 4 failed | 9 passed (13) — the four cases that need a pick
```
</details>
<details><summary>Type check</summary>
```
pnpm --filter @object-ui/app-shell type-check (tsc --noEmit && tsc -p tsconfig.test.json)
os-verify-lock: VERDICT command-exit 0
pnpm build (turbo, includes @object-ui/console tsc + vite build) on 3e162ec
os-verify-lock: VERDICT command-exit 0
```
</details>
## Verification
Live: showcase `pnpm dev -- --fresh` plus the console on vite,
Playwright `en-US`, 1440 wide. Users created by the seeded admin. Row 1
before = this head with step 0 disabled in place (`ablation-replace
--hold`, restored after).
| Row | Who | Picked | `sys_user.locale` after sign-in | Slot | Language
after reload |
|---|---|---|---|---|---|
| 1 before | no language of their own | Français | empty | fr | fr |
| 1 after | no language of their own (0 orgs, root exit) | Français |
**fr** | fr | fr |
| 2 after | has `de` | Français | de (unchanged) | fr | fr |
| 3 after | no language of their own | nothing | empty, no PATCH sent |
empty | en-US |
| 4 after | seeded admin (1 org, final exit) | Français | **fr** | fr |
fr |
Requests in row 1 after, in order: `POST sign-in/email`, `GET
discovery`, `GET sys_user/ID`, `PATCH sys_user/ID`, then the next
page-load's `GET discovery`.
<details><summary>Gates (merged head 3e162ec)</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 35; all 35 exit 0, codes captured
before any pipe. `pnpm --dir ui exec vitest run scripts/` was killed
once by the container's memory limit while other gates ran (exit 1,
SIGKILL) and re-run alone: 170 passed, 2 skipped files, exit 0. Extra,
outside the derivation: `pnpm --dir ui run check:spec-symbols` exit 0;
`node scripts/check-sdui-manifest.mjs` exit 0 (also derived).
`dispatch-gates --ran`: 35 derived, 35 run, 0 NOT-MEASURED (a derived
zero).
</details>
Branch `claude/project-thread-3ct8gd` is the card thread's
platform-assigned branch (not `claude/issue-30399-*`).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01L6bhgUkfsVq9sKkVSugapT
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L6bhgUkfsVq9sKkVSugapT)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent bcdf8d3 commit bbd6072
7 files changed
Lines changed: 497 additions & 2 deletions
File tree
- .changeset
- ui
- apps/console/src/pages/auth
- __tests__
- packages
- app-shell/src
- hooks
- __tests__
- plugin-form/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
| 73 | + | |
| 74 | + | |
73 | 75 | | |
74 | 76 | | |
75 | 77 | | |
| |||
153 | 155 | | |
154 | 156 | | |
155 | 157 | | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
156 | 163 | | |
157 | 164 | | |
158 | 165 | | |
| |||
244 | 251 | | |
245 | 252 | | |
246 | 253 | | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
247 | 275 | | |
248 | 276 | | |
249 | 277 | | |
| |||
310 | 338 | | |
311 | 339 | | |
312 | 340 | | |
| 341 | + | |
313 | 342 | | |
314 | 343 | | |
315 | 344 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
0 commit comments