Skip to content

Commit 2b3341f

Browse files
fix(react): finalize ticket flows started before Clerk loads (#9628)
Co-authored-by: Dylan Staley <88163+dstaley@users.noreply.github.com>
1 parent 689622f commit 2b3341f

3 files changed

Lines changed: 177 additions & 3 deletions

File tree

.changeset/warm-tickets-finish.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/react': patch
3+
---
4+
5+
Ensure ticket-based sign-in and sign-up flows started before Clerk finishes loading can be finalized successfully.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import type { SignInFutureResource, SignUpFutureResource } from '@clerk/shared/types';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { StateProxy } from '../stateProxy';
5+
6+
describe('StateProxy', () => {
7+
it('preserves a completed sign-in across chained calls when the client clears its sign-in attempt', async () => {
8+
const emptySignIn = {
9+
status: 'needs_identifier',
10+
createdSessionId: null as string | null,
11+
ticket: vi.fn(() => Promise.resolve({ error: null })),
12+
finalize: vi.fn(() => Promise.reject(new Error('Cannot finalize sign-in without a created session.'))),
13+
};
14+
const completedSignIn = {
15+
status: 'needs_identifier',
16+
createdSessionId: null as string | null,
17+
ticket: vi.fn(() => {
18+
client.signIn = { __internal_future: emptySignIn };
19+
completedSignIn.status = 'complete';
20+
completedSignIn.createdSessionId = 'sess_123';
21+
return Promise.resolve({ error: null });
22+
}),
23+
finalize: vi.fn(() => Promise.resolve({ error: null })),
24+
};
25+
const client = {
26+
signIn: { __internal_future: completedSignIn },
27+
};
28+
const state = {
29+
signInSignal: () => ({ signIn: completedSignIn }),
30+
};
31+
const loadedCallbacks: Array<() => void> = [];
32+
const isomorphicClerk = {
33+
loaded: false,
34+
client,
35+
__internal_state: state,
36+
addOnLoaded: vi.fn((callback: () => void) => loadedCallbacks.push(callback)),
37+
};
38+
const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn as SignInFutureResource;
39+
40+
const ticketPromise = signIn.ticket({ ticket: 'ticket_123' });
41+
expect(isomorphicClerk.addOnLoaded).toHaveBeenCalledOnce();
42+
expect(completedSignIn.ticket).not.toHaveBeenCalled();
43+
44+
isomorphicClerk.loaded = true;
45+
loadedCallbacks.forEach(callback => callback());
46+
await ticketPromise;
47+
48+
await expect(signIn.finalize()).resolves.toEqual({ error: null });
49+
expect(signIn.status).toBe('complete');
50+
expect(signIn.createdSessionId).toBe('sess_123');
51+
expect(completedSignIn.finalize).toHaveBeenCalledOnce();
52+
expect(emptySignIn.finalize).not.toHaveBeenCalled();
53+
});
54+
55+
it('preserves a completed sign-up across chained calls when the client clears its sign-up attempt', async () => {
56+
const emptySignUp = {
57+
status: 'missing_requirements',
58+
createdSessionId: null as string | null,
59+
ticket: vi.fn(() => Promise.resolve({ error: null })),
60+
finalize: vi.fn(() =>
61+
Promise.resolve({ error: new Error('Cannot finalize sign-up without a created session.') }),
62+
),
63+
};
64+
const completedSignUp = {
65+
status: 'missing_requirements',
66+
createdSessionId: null as string | null,
67+
ticket: vi.fn(() => {
68+
client.signUp = { __internal_future: emptySignUp };
69+
completedSignUp.status = 'complete';
70+
completedSignUp.createdSessionId = 'sess_123';
71+
return Promise.resolve({ error: null });
72+
}),
73+
finalize: vi.fn(() => Promise.resolve({ error: null })),
74+
};
75+
const client: {
76+
signUp: { __internal_future: typeof completedSignUp | typeof emptySignUp };
77+
} = {
78+
signUp: { __internal_future: completedSignUp },
79+
};
80+
const state = {
81+
signUpSignal: () => ({ signUp: completedSignUp }),
82+
};
83+
const loadedCallbacks: Array<() => void> = [];
84+
const isomorphicClerk = {
85+
loaded: false,
86+
client,
87+
__internal_state: state,
88+
addOnLoaded: vi.fn((callback: () => void) => loadedCallbacks.push(callback)),
89+
};
90+
const signUp = new StateProxy(isomorphicClerk as any).signUpSignal().signUp as SignUpFutureResource;
91+
92+
const ticketPromise = signUp.ticket({ ticket: 'ticket_123' });
93+
expect(isomorphicClerk.addOnLoaded).toHaveBeenCalledOnce();
94+
expect(completedSignUp.ticket).not.toHaveBeenCalled();
95+
96+
isomorphicClerk.loaded = true;
97+
loadedCallbacks.forEach(callback => callback());
98+
await ticketPromise;
99+
100+
await expect(signUp.finalize()).resolves.toEqual({ error: null });
101+
expect(signUp.status).toBe('complete');
102+
expect(signUp.createdSessionId).toBe('sess_123');
103+
expect(completedSignUp.finalize).toHaveBeenCalledOnce();
104+
expect(emptySignUp.finalize).not.toHaveBeenCalled();
105+
});
106+
107+
it('falls back to the client sign-in when the state signal is empty', async () => {
108+
const clientSignIn = {
109+
status: 'needs_first_factor',
110+
create: vi.fn(() => Promise.resolve({ error: null })),
111+
};
112+
const isomorphicClerk = {
113+
loaded: true,
114+
client: { signIn: { __internal_future: clientSignIn } },
115+
__internal_state: { signInSignal: () => ({ signIn: null }) },
116+
};
117+
const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn as SignInFutureResource;
118+
119+
await expect(signIn.create({ identifier: 'test@example.com' })).resolves.toEqual({ error: null });
120+
expect(signIn.status).toBe('needs_first_factor');
121+
expect(clientSignIn.create).toHaveBeenCalledOnce();
122+
});
123+
124+
it('uses a newer sign-in from the state signal instead of the client attempt', async () => {
125+
const clientSignIn = {
126+
finalize: vi.fn(() => Promise.reject(new Error('Finalized the stale client sign-in.'))),
127+
};
128+
const currentSignIn = {
129+
finalize: vi.fn(() => Promise.resolve({ error: null })),
130+
};
131+
const isomorphicClerk = {
132+
loaded: true,
133+
client: { signIn: { __internal_future: clientSignIn } },
134+
__internal_state: { signInSignal: () => ({ signIn: currentSignIn }) },
135+
};
136+
const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn as SignInFutureResource;
137+
138+
await expect(signIn.finalize()).resolves.toEqual({ error: null });
139+
expect(currentSignIn.finalize).toHaveBeenCalledOnce();
140+
expect(clientSignIn.finalize).not.toHaveBeenCalled();
141+
});
142+
143+
it('falls back to the fresh client sign-in after the retained state attempt is cleared', async () => {
144+
const clientSignIn = {
145+
status: 'needs_identifier',
146+
};
147+
let stateSignIn: { status: string; finalize: ReturnType<typeof vi.fn> } | null;
148+
const completedSignIn = {
149+
status: 'complete',
150+
finalize: vi.fn(() => {
151+
stateSignIn = null;
152+
return Promise.resolve({ error: null });
153+
}),
154+
};
155+
stateSignIn = completedSignIn;
156+
const isomorphicClerk = {
157+
loaded: true,
158+
client: { signIn: { __internal_future: clientSignIn } },
159+
__internal_state: { signInSignal: () => ({ signIn: stateSignIn }) },
160+
};
161+
const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn as SignInFutureResource;
162+
163+
expect(signIn.status).toBe('complete');
164+
await expect(signIn.finalize()).resolves.toEqual({ error: null });
165+
expect(signIn.status).toBe('needs_identifier');
166+
});
167+
});

packages/react/src/stateProxy.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,15 @@ export class StateProxy implements State {
134134

135135
private buildSignInProxy() {
136136
const gateProperty = this.gateProperty.bind(this);
137-
const target = () => this.client.signIn.__internal_future;
137+
const target = () => this.state.signInSignal().signIn ?? this.client.signIn.__internal_future;
138138

139139
return {
140140
errors: defaultSignInErrors(),
141141
fetchStatus: 'idle' as const,
142142
signIn: {
143-
status: 'needs_identifier' as const,
143+
get status() {
144+
return gateProperty(target, 'status', 'needs_identifier');
145+
},
144146
availableStrategies: [],
145147
get isTransferable() {
146148
return gateProperty(target, 'isTransferable', false);
@@ -255,7 +257,7 @@ export class StateProxy implements State {
255257
private buildSignUpProxy() {
256258
const gateProperty = this.gateProperty.bind(this);
257259
const gateMethod = this.gateMethod.bind(this);
258-
const target = () => this.client.signUp.__internal_future;
260+
const target = () => this.state.signUpSignal().signUp ?? this.client.signUp.__internal_future;
259261

260262
return {
261263
errors: defaultSignUpErrors(),

0 commit comments

Comments
 (0)