Skip to content

Commit 4831d50

Browse files
fix(console): save a language picked on the register page as the new person's own when they have none (#30597)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuTbUduB1ABMv1ARhxiJJCEa)_ Fixes #30338 domain:ui#1 · os-sales · session_01L6bhgWn6zhNa4LL7VB1rtL ## 维护者速读 - 改了什么:在注册页底部选了语言、本人还没设过语言偏好的,注册即登录时这个选择会存成本人的语言偏好(`sys_user.locale`),和登录页(#30399)一样;从邀请链接注册加入组织也一样。 - 为什么改:#30399 只改了登录页;注册页同样靠整页跳转离开,跳转前没人写库,选的语言只留在这台浏览器上。 - 风险与回滚:只有注册页选过语言才多发两次请求(读一次本人记录、写一次),已有偏好不改,没选不发不等,写失败照常进入。回滚即撤本 PR。 - 先看:console `RegisterPage.tsx` 注册后 effect 新增的第 0 步;app-shell `RegisterPage.tsx` 的 `holdForSignInPick`。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/pages/auth/__tests__/registerLanguagePick-30338.test.tsx packages/app-shell/src/console/auth/__tests__/registerLanguagePick-30338.test.tsx`,预期 25 passed。 - 需要你做的:无,席位落地。 Clause-②: no Metadata-protocol: none ## What changes - `ui/apps/console/src/pages/auth/RegisterPage.tsx`, the post-signup orchestration effect only: a new step 0, the same as `LoginPage`'s (#30539). When a language was picked on the page while signed out, it starts app-shell's `saveSignInLanguagePick` once (ref latch), holds the spinner, and re-runs the effect when the save settles (state). The steps after it are unchanged, so every exit waits: | Exit | Line on `main` | Waits | |---|---|---| | OAuth provider hand-off (`followOauthAuthorize`) | L194 | yes (step 0 sits above it, as on `/login`) | | Mobile sign-in hand-off | L206 | yes; a session the hand-off refuses for its age saves nothing | | Invitation join entered (#30125) | L218 | yes; the acceptance runs meanwhile | | No organization | L233 | yes | | Several organizations | L236 | yes | | Final redirect after the single-org auto-select | L241 | yes | - `ui/packages/app-shell/src/console/auth/RegisterPage.tsx` (`DefaultRegisterPage`): this page draws no language menu, so a pick reaches it only from another signed-out page earlier in the same page-load. Once someone is signed in and a pick is held, the page starts the save once and renders its empty layout until it settles (`holdForSignInPick`, from state plus `hasSignInLanguagePick()`, so the render that first sees the user already holds). That covers its three ways out: the invitation join's `window.location.assign` to the root (L132), the signed-in branch's `Navigate` to `/`, and the form's `onSuccess` `navigate('/')` (L271), which now leaves the move to the signed-in branch when a pick is held or its save has started. - Imports only: `hasSignInLanguagePick` / `saveSignInLanguagePick` as #30539 landed them. No new export, no new storage key, nothing sent beside a navigation. ## Why Measured on `origin/main` `27b0bc79`: neither register page called `saveSignInLanguagePick`, and both leave by navigation after a sign-up that signs the person in, so a pick made there stayed in the device slot only. This is the remainder recorded in the `domain:ui#4` ACCEPT on #30539 ("`RegisterPage.tsx` 同形") and claimed in 6663446873. ## Acceptance notes - **Bound, as ruled (seat ruling D on this card, carried by the claim):** memory lasts one page-load. A pick followed by a reload of the register page, or by an identity provider round trip, is still not saved. No storage marker was added to change that. - **No timeout on the save**, the same as `/login`: a hanging request keeps the spinner up as long as the adapter's own fetch does. - **Write census:** no new adapter write call site. The pages call `saveSignInLanguagePick`, whose own `update` is already registered in `affordanceGrantMap-12082.test.tsx` (`outOfFamily`), and that function is not a census write helper. The census test ran green unchanged. - **Observed, not this card's:** with no pick and several organizations, the post-signup step navigates to the root before the organization list answers, then again to the picker, the same double navigation #30539 noted on `/login`. The CONTROL pins assert only that the page leaves and saves nothing. - Untouched: `LoginPage.tsx`, `useUserLocale.ts`, `loadLocales.ts`'s signed-out list, `packages/**`. - Branch `claude/project-thread-b4xtgz` is the card thread's platform-assigned branch (not `claude/issue-30338-*`). ## Tests - `ui/apps/console/src/pages/auth/__tests__/registerLanguagePick-30338.test.tsx` (new, 19 cases, each reached by a sign-up on the form): for each of the six exits, nothing leaves while the save is in flight and the expected exit is taken once it settles; the invitation join accepts its invitation and still waits; CONTROL for each exit with no pick; for each exit, a save that settles having failed still lets the page leave. - `ui/packages/app-shell/src/console/auth/__tests__/registerLanguagePick-30338.test.tsx` (new, 6 cases): sign-up to `/` and invitation join to the root each wait for the save; CONTROL for both with no pick; a failed save still moves on, both ways. <details><summary>vitest: both new files, every console and app-shell auth page test, the sign-in pick hook file and the write census (head e15bb2e)</summary> ``` pnpm exec vitest run --maxWorkers=2 apps/console/src/pages/auth/ packages/app-shell/src/console/auth/ packages/app-shell/src/hooks/__tests__/userLocaleSignInPick-30338.test.tsx packages/plugin-form/src/affordanceGrantMap-12082.test.tsx Test Files 47 passed (47) Tests 404 passed (404) 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 console RegisterPage: drop step 0's return (the save starts, the first effect run goes on to the exit) registerLanguagePick-30338 (console): 8 failed | 11 passed (19), exit 1 red: the five non-invitation "waits" cases and three "failed save" cases; green: CONTROLs and both invitation cases (a later effect run still meets the 'saving' guard, so the invitation exit stays held: hence A1b) A1b console RegisterPage: delete the "save in flight" guard line registerLanguagePick-30338 (console): 4 failed | 15 passed (19), exit 1 red: several organizations, one organization, both invitation cases A2 app-shell RegisterPage: holdForSignInPick forced false registerLanguagePick-30338 (app-shell): 2 failed | 4 passed (6), exit 1 red: both "waits" cases; green: CONTROLs and both failed-save cases ``` Together every exit has a waits case that goes red when the wait is removed. </details> <details><summary>Type check</summary> ``` pnpm --filter @object-ui/app-shell type-check (tsc --noEmit && tsc -p tsconfig.test.json) exit 0 ui/apps/console: NODE_OPTIONS=--max-old-space-size=6144 tsc --noEmit exit 0 eslint on the four changed files: 0 errors; 2 warnings, both present on main (setState in effect, L145 and L258) ``` </details> ## Verification Live: showcase `pnpm dev -- --fresh` (invite_only) plus the console on vite, Playwright `zh-CN`, 1440 wide. The seeded admin invites a fresh address; the invitee opens the link signed out, follows "注册并加入", picks Français in the register page's footer menu, registers, and lands in the app. Then, from the invitee's own session: `sys_user.locale`, the device slot, the document's `lang` attribute. Before = this head with the console `RegisterPage.tsx` restored from `27b0bc79` (vite reads source), put back from HEAD after (`git status` clean). Script and screenshots: project files `issue-30338/`. | Row | Picked | Landed | `sys_user.locale` | Slot | `lang` | |---|---|---|---|---|---| | before | Français | app, joined | empty | fr | fr | | after | Français | app, joined | **fr** | fr | fr | | after, CONTROL | nothing | app, joined | empty, no `PATCH` sent | empty | zh-CN | Requests in the after row, in order: `POST sign-up/email`, `GET get-invitation`, `POST accept-invitation`, `POST set-active`, and on the data side `GET discovery`, `GET sys_user/ID`, `PATCH sys_user/ID`, then the next page-load's `GET discovery`. <details><summary>Gates (head e15bb2e)</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/`: 170 passed, 2 skipped files). `dispatch-gates --ran`: 35 derived, 35 run, 0 NOT-MEASURED (a derived zero). Changed lines 619, under the 3000 threshold. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUaeNktxjHB4qxqPKg --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUaeNktxjHB4qxqPKg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 08e0247 commit 4831d50

5 files changed

Lines changed: 614 additions & 5 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(console): a language picked on the register page is saved as the new person's own language when registering signs them in (#30338)
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
A language picked in the register page's menu used to apply on that browser only: once the sign-up signed the person in, their saved language stayed empty, so another device, or the language their mail is written in, did not follow the pick. The register page now saves the pick to the person's own language setting before it moves on, the way the sign-in page does, on every way it leaves after a sign-up, joining an organization from an invitation link included. `DefaultRegisterPage` does the same for a pick made earlier on another signed-out page. Nothing is saved when no language was picked, a language the person already has is kept, and a save that fails does not hold up the sign-up (the pick still applies on that browser). A pick followed by a reload of the register page, or by an identity provider's redirect, is still not saved.

‎ui/apps/console/src/pages/auth/RegisterPage.tsx‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
* page and then on "Invitation accepted" (#30125, app-shell's
3939
* `useInvitationSignUpJoin`). An address the server refuses goes on to the
4040
* accept page as before, which shows the refusal.
41+
* - A language picked in the footer's menu before signing up is saved as the
42+
* new person's own (app-shell's `saveSignInLanguagePick`), awaited before
43+
* every exit of the post-signup step, as `/login` does (#30338).
4144
*/
4245

4346
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
@@ -57,6 +60,8 @@ import {
5760
invitationArrivalCopy,
5861
useInvitationArrival,
5962
useInvitationSignUpJoin,
63+
hasSignInLanguagePick,
64+
saveSignInLanguagePick,
6065
} from '@object-ui/app-shell';
6166
import { AuthLayout } from './AuthLayout';
6267
import { followOauthAuthorize } from './followAuthorize';
@@ -124,6 +129,12 @@ export function RegisterPage() {
124129
const mobileHandoffStartedRef = useRef(false);
125130
// And the entry into the organization an invitation sign-up joined (#30125).
126131
const invitationEntryStartedRef = useRef(false);
132+
// #30338 — a language picked on this page, saved as the new person's own
133+
// before any exit below navigates away (as on `/login`). The ref is the
134+
// once-only latch; the state re-runs the post-signup effect when the save
135+
// has settled.
136+
const signInPickSaveRef = useRef<'idle' | 'saving' | 'settled'>('idle');
137+
const [signInPickSaved, setSignInPickSaved] = useState(false);
127138

128139
// See LoginPage: `isLoading` is overloaded (initial session check + every
129140
// in-flight signUp). Latch once the first check resolves so a failed
@@ -186,6 +197,27 @@ export function RegisterPage() {
186197
useEffect(() => {
187198
if (!user) return;
188199

200+
// 0. A language picked on this page while signed out (#30338): saved as
201+
// this person's own when they have none, and AWAITED before every exit
202+
// below — each leaves by a full navigation, which ends the page-load
203+
// that knows the pick was made here, and a write sent beside it races
204+
// the unload. Without a pick nothing is sent and nothing waits. A failed
205+
// save does not hold the sign-up: the pick still applies on this device.
206+
// A session the hand-off would refuse is not a sign-in yet: skipped.
207+
if (signInPickSaveRef.current === 'saving') return;
208+
if (
209+
signInPickSaveRef.current === 'idle' &&
210+
!handoff.holdForSignIn &&
211+
hasSignInLanguagePick()
212+
) {
213+
signInPickSaveRef.current = 'saving';
214+
void saveSignInLanguagePick(String(user.id)).finally(() => {
215+
signInPickSaveRef.current = 'settled';
216+
setSignInPickSaved(true);
217+
});
218+
return;
219+
}
220+
189221
if (typeof window !== 'undefined') {
190222
const sp = new URLSearchParams(window.location.search);
191223
if (sp.has('client_id') && sp.has('redirect_uri')) {
@@ -250,6 +282,7 @@ export function RegisterPage() {
250282
handoff.target,
251283
handoff.holdForSignIn,
252284
invitationJoin,
285+
signInPickSaved,
253286
]);
254287

255288
// The first session check has answered and nobody is signed in: the page is
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
/**
2+
* The console's `/register` saves a language picked on it before it navigates
3+
* away, once registering has signed the person in (objectstack#30338, the
4+
* register-page remainder of the save `/login` makes since objectstack#30399).
5+
*
6+
* The knowledge that the pick was made on this page is memory, and every exit
7+
* of the post-signup step is a full navigation that ends it. So the page awaits
8+
* app-shell's `saveSignInLanguagePick` before each exit — a write sent beside
9+
* the navigation races the unload. What the save writes, when it writes
10+
* nothing, and that a failed read or write resolves instead of rejecting, are
11+
* app-shell's and pinned beside the pick itself
12+
* (`userLocaleSignInPick-30338.test.tsx`); this file pins the ORDER on the
13+
* page, one case per exit, each reached by a sign-up on the form:
14+
*
15+
* 1. with a pick, nothing navigates while the save is in flight, and the exit
16+
* the page would have taken is taken once it settles;
17+
* 2. without a pick, nothing is saved and the page leaves (CONTROL);
18+
* 3. a save that fails (it settles having written nothing) still lets the
19+
* page leave.
20+
*
21+
* The invitation join (objectstack#30125) is one of the exits: the acceptance
22+
* and the save both finish before the page enters the root.
23+
*
24+
* The real `AuthProvider` runs over a mock client, as in
25+
* `invitationSignUpJoin-30125.test.tsx`; the Console is mounted as the shipped
26+
* build is (`<base href="/_console/">`).
27+
*/
28+
29+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
30+
import { render, screen, cleanup, waitFor } from '@testing-library/react';
31+
import userEvent from '@testing-library/user-event';
32+
import { MemoryRouter } from 'react-router-dom';
33+
import { AuthProvider } from '@object-ui/auth';
34+
import type { AuthClient } from '@object-ui/auth';
35+
36+
const pick = vi.hoisted(() => ({
37+
held: false,
38+
save: vi.fn<(userId: string) => Promise<void>>(),
39+
}));
40+
41+
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
42+
...(await importOriginal<Record<string, unknown>>()),
43+
hasSignInLanguagePick: () => pick.held,
44+
saveSignInLanguagePick: (userId: string) => pick.save(userId),
45+
}));
46+
47+
const follow = vi.hoisted(() => vi.fn());
48+
vi.mock('../followAuthorize', () => ({ followOauthAuthorize: follow }));
49+
50+
import { RegisterPage } from '../RegisterPage';
51+
52+
const HANDOFF =
53+
'/api/v1/auth/one-time-token/handoff?redirect_uri=ai.objectos.mobile%3A%2F%2Fauth%2Fcallback' +
54+
'&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256';
55+
const OAUTH = '?client_id=rp&redirect_uri=https%3A%2F%2Frp.example%2Fcb&state=s';
56+
const INVITATION_ID = 'inv_30338';
57+
const ACCEPT_PATH = `/accept-invitation/${INVITATION_ID}`;
58+
const ORG = { id: 'org_acme', name: 'Acme', slug: 'acme' };
59+
60+
const USER = { id: 'u_new', name: 'Zhang', email: 'zhang@example.com' };
61+
62+
let baseEl: HTMLBaseElement | null = null;
63+
let assign: ReturnType<typeof vi.fn>;
64+
/** Settles the save the page is awaiting. */
65+
let settleSave: () => void;
66+
67+
beforeEach(() => {
68+
window.localStorage.clear();
69+
baseEl = document.createElement('base');
70+
baseEl.setAttribute('href', '/_console/');
71+
document.head.appendChild(baseEl);
72+
vi.stubEnv('BASE_URL', './');
73+
assign = vi.fn();
74+
vi.spyOn(window.location, 'assign').mockImplementation(assign as never);
75+
vi.spyOn(console, 'warn').mockImplementation(() => {});
76+
follow.mockReset();
77+
pick.held = true;
78+
pick.save.mockReset();
79+
pick.save.mockImplementation(
80+
() =>
81+
new Promise<void>((resolve) => {
82+
settleSave = () => {
83+
pick.held = false;
84+
resolve();
85+
};
86+
}),
87+
);
88+
});
89+
90+
afterEach(() => {
91+
cleanup();
92+
baseEl?.remove();
93+
baseEl = null;
94+
vi.unstubAllEnvs();
95+
vi.restoreAllMocks();
96+
});
97+
98+
type Orgs = Array<{ id: string; name: string; slug: string }>;
99+
100+
/**
101+
* A server on which the form's sign-up signs the person in. `organizations`
102+
* absent: the client has no organization calls (none to choose from).
103+
* `invitation`: the page is opened from an invitation link, which the sign-up
104+
* accepts, joining `ORG`.
105+
*/
106+
function createMockClient({ organizations, invitation = false }: { organizations?: Orgs; invitation?: boolean } = {}) {
107+
let signedIn = false;
108+
let member = false;
109+
let active: { id: string; name: string; slug: string } | null = null;
110+
const orgs = (): Orgs => (invitation ? (member ? [ORG] : []) : (organizations ?? []));
111+
const client = {
112+
getSession: vi.fn(async () => (signedIn ? { user: USER, session: { token: 't', createdAt: new Date() } } : null)),
113+
getConfig: vi.fn().mockResolvedValue({
114+
emailPassword: { enabled: true, disableSignUp: false, requireEmailVerification: false },
115+
features: { audiencePosture: invitation ? 'invite_only' : 'open' },
116+
}),
117+
signUp: vi.fn(async () => {
118+
signedIn = true;
119+
return { user: USER, session: undefined, requiresVerification: false };
120+
}),
121+
signIn: vi.fn(),
122+
signInWithProvider: vi.fn(),
123+
...(invitation
124+
? {
125+
getInvitationPreview: vi.fn().mockResolvedValue({
126+
organizationName: ORG.name,
127+
inviterName: 'Lin',
128+
email: 'z***@example.com',
129+
}),
130+
getInvitation: vi.fn(async (id: string) => ({
131+
id,
132+
organizationId: ORG.id,
133+
email: USER.email,
134+
role: 'member',
135+
status: 'pending',
136+
})),
137+
acceptInvitation: vi.fn(async () => {
138+
member = true;
139+
}),
140+
getActiveMember: vi.fn().mockResolvedValue(null),
141+
}
142+
: {}),
143+
...(organizations || invitation
144+
? {
145+
listOrganizations: vi.fn(async () => orgs()),
146+
getActiveOrganization: vi.fn(async () => active),
147+
setActiveOrganization: vi.fn(async (id: string) => {
148+
active = orgs().find((o) => o.id === id) ?? null;
149+
return active;
150+
}),
151+
}
152+
: {}),
153+
};
154+
return client as typeof client & AuthClient;
155+
}
156+
157+
function renderRegister(client: AuthClient, search = '') {
158+
window.history.replaceState({}, '', `/_console/register${search}`);
159+
return render(
160+
<AuthProvider authUrl="/api/v1/auth" client={client}>
161+
<MemoryRouter initialEntries={[`/register${search}`]}>
162+
<RegisterPage />
163+
</MemoryRouter>
164+
</AuthProvider>,
165+
);
166+
}
167+
168+
async function signUpOnTheForm() {
169+
const user = userEvent.setup();
170+
await user.type(await screen.findByLabelText('Name'), USER.name);
171+
await user.type(screen.getByLabelText('Email'), USER.email);
172+
await user.type(screen.getByLabelText('Password'), 'hunter2hunter2');
173+
await user.type(screen.getByLabelText('Confirm Password'), 'hunter2hunter2');
174+
await user.click(screen.getByRole('button', { name: 'Create Account' }));
175+
}
176+
177+
/** Where a full-page navigation lands, per the browser's own resolution. */
178+
const lands = (target: string) => new URL(target, document.baseURI).pathname;
179+
/** Long enough for every effect and microtask the page would run before an exit. */
180+
const settle = () => new Promise((r) => setTimeout(r, 50));
181+
const assigned = () => (assign.mock.calls[0] ? lands(assign.mock.calls[0][0] as string) : null);
182+
183+
const EXITS: Array<{
184+
exit: string;
185+
search: string;
186+
client: () => ReturnType<typeof createMockClient>;
187+
left: () => string | null;
188+
expected: string;
189+
}> = [
190+
{
191+
exit: 'no organization: the Console root',
192+
search: '',
193+
client: () => createMockClient(),
194+
left: assigned,
195+
expected: '/_console/',
196+
},
197+
{
198+
exit: 'several organizations, none active: the picker',
199+
search: '',
200+
client: () =>
201+
createMockClient({
202+
organizations: [
203+
{ id: 'org_1', name: 'One', slug: 'one' },
204+
{ id: 'org_2', name: 'Two', slug: 'two' },
205+
],
206+
}),
207+
left: assigned,
208+
expected: '/_console/organizations',
209+
},
210+
{
211+
exit: 'one organization: selected, then the safe redirect',
212+
search: `?redirect=${encodeURIComponent('/settings')}`,
213+
client: () => createMockClient({ organizations: [{ id: 'org_1', name: 'One', slug: 'one' }] }),
214+
left: assigned,
215+
expected: '/_console/settings',
216+
},
217+
{
218+
exit: 'an invitation link: joined, then the Console root',
219+
search: `?redirect=${encodeURIComponent(ACCEPT_PATH)}`,
220+
client: () => createMockClient({ invitation: true }),
221+
left: assigned,
222+
expected: '/_console/',
223+
},
224+
{
225+
exit: 'the mobile sign-in hand-off',
226+
search: `?redirect=${encodeURIComponent(HANDOFF)}`,
227+
client: () => createMockClient(),
228+
left: () => (assign.mock.calls[0] ? (assign.mock.calls[0][0] as string) : null),
229+
expected: HANDOFF,
230+
},
231+
{
232+
exit: 'the OAuth provider hand-off',
233+
search: OAUTH,
234+
client: () => createMockClient(),
235+
left: () => (follow.mock.calls[0] ? (follow.mock.calls[0][0] as string) : null),
236+
expected: OAUTH,
237+
},
238+
];
239+
240+
describe('console /register — a language picked here is saved before the page leaves (objectstack#30338)', () => {
241+
it.each(EXITS)('$exit: nothing leaves while the save is in flight, then the page leaves', async (c) => {
242+
renderRegister(c.client(), c.search);
243+
await signUpOnTheForm();
244+
245+
await waitFor(() => expect(pick.save).toHaveBeenCalledTimes(1));
246+
expect(pick.save).toHaveBeenCalledWith(USER.id);
247+
await settle();
248+
expect(assign).not.toHaveBeenCalled();
249+
expect(follow).not.toHaveBeenCalled();
250+
251+
settleSave();
252+
await waitFor(() => expect(c.left()).toBe(c.expected));
253+
expect(assign.mock.calls.length + follow.mock.calls.length).toBe(1);
254+
expect(pick.save).toHaveBeenCalledTimes(1);
255+
});
256+
257+
it('an invitation link: the invitation is accepted, and the root waits for the save as well', async () => {
258+
const client = createMockClient({ invitation: true });
259+
renderRegister(client, `?redirect=${encodeURIComponent(ACCEPT_PATH)}`);
260+
await signUpOnTheForm();
261+
262+
await waitFor(() => expect(client.acceptInvitation).toHaveBeenCalledWith(INVITATION_ID));
263+
await waitFor(() => expect(client.setActiveOrganization).toHaveBeenCalledWith(ORG.id));
264+
await settle();
265+
expect(assign).not.toHaveBeenCalled();
266+
267+
settleSave();
268+
await waitFor(() => expect(assign).toHaveBeenCalledTimes(1));
269+
expect(lands(assign.mock.calls[0][0] as string)).toBe('/_console/');
270+
});
271+
272+
// Which exit, and how often, is not this file's to pin here: with nothing
273+
// to wait for, a page whose organizations are still loading can navigate
274+
// before they answer and again after — the post-signup step's own
275+
// behaviour, unchanged by this card (the same holds on `/login`).
276+
it.each(EXITS)('CONTROL — $exit, with no pick: nothing is saved and the page leaves', async (c) => {
277+
pick.held = false;
278+
renderRegister(c.client(), c.search);
279+
await signUpOnTheForm();
280+
281+
await waitFor(() =>
282+
expect(assign.mock.calls.length + follow.mock.calls.length).toBeGreaterThanOrEqual(1),
283+
);
284+
expect(pick.save).not.toHaveBeenCalled();
285+
});
286+
287+
it.each(EXITS)('$exit: a save that fails still lets the page leave', async (c) => {
288+
// The save's own contract (app-shell): a failed read or write is logged and
289+
// the promise resolves, having written nothing.
290+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
291+
pick.save.mockImplementation(async () => {
292+
pick.held = false;
293+
console.error('[app-shell] The language picked on the sign-in page was not saved', new Error('refused'));
294+
});
295+
renderRegister(c.client(), c.search);
296+
await signUpOnTheForm();
297+
298+
await waitFor(() => expect(c.left()).toBe(c.expected));
299+
expect(pick.save).toHaveBeenCalledTimes(1);
300+
expect(error).toHaveBeenCalledTimes(1);
301+
expect(assign.mock.calls.length + follow.mock.calls.length).toBe(1);
302+
});
303+
});

0 commit comments

Comments
 (0)