From 9c0de524ef2c00d77a455f6a2fedeb7c365b7ca0 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 1 Sep 2026 02:50:39 +0400 Subject: [PATCH 1/3] fix(react): finalize ticket flows started before Clerk loads --- .changeset/warm-tickets-finish.md | 5 + .../react/src/__tests__/stateProxy.test.ts | 150 ++++++++++++++++++ packages/react/src/stateProxy.ts | 8 +- 3 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 .changeset/warm-tickets-finish.md create mode 100644 packages/react/src/__tests__/stateProxy.test.ts diff --git a/.changeset/warm-tickets-finish.md b/.changeset/warm-tickets-finish.md new file mode 100644 index 00000000000..be3a9a5c32f --- /dev/null +++ b/.changeset/warm-tickets-finish.md @@ -0,0 +1,5 @@ +--- +'@clerk/react': patch +--- + +Ensure ticket-based sign-in and sign-up flows started before Clerk finishes loading can be finalized successfully. diff --git a/packages/react/src/__tests__/stateProxy.test.ts b/packages/react/src/__tests__/stateProxy.test.ts new file mode 100644 index 00000000000..818c442f7a8 --- /dev/null +++ b/packages/react/src/__tests__/stateProxy.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { StateProxy } from '../stateProxy'; + +describe('StateProxy', () => { + it('preserves a completed sign-in across chained calls when the client clears its sign-in attempt', async () => { + const emptySignIn = { + status: 'needs_identifier', + createdSessionId: null as string | null, + ticket: vi.fn(() => Promise.resolve({ error: null })), + finalize: vi.fn(() => Promise.reject(new Error('Cannot finalize sign-in without a created session.'))), + }; + const completedSignIn = { + status: 'needs_identifier', + createdSessionId: null as string | null, + ticket: vi.fn(() => { + client.signIn = { __internal_future: emptySignIn }; + completedSignIn.status = 'complete'; + completedSignIn.createdSessionId = 'sess_123'; + return Promise.resolve({ error: null }); + }), + finalize: vi.fn(() => Promise.resolve({ error: null })), + }; + const client = { + signIn: { __internal_future: completedSignIn }, + }; + const state = { + signInSignal: () => ({ signIn: completedSignIn }), + }; + const isomorphicClerk = { + loaded: false, + client, + __internal_state: state, + }; + const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn; + + isomorphicClerk.loaded = true; + await signIn.ticket({ ticket: 'ticket_123' }); + + await expect(signIn.finalize()).resolves.toEqual({ error: null }); + expect(signIn.status).toBe('complete'); + expect(signIn.createdSessionId).toBe('sess_123'); + expect(completedSignIn.finalize).toHaveBeenCalledOnce(); + expect(emptySignIn.finalize).not.toHaveBeenCalled(); + }); + + it('preserves a completed sign-up across chained calls when the client clears its sign-up attempt', async () => { + const emptySignUp = { + status: 'missing_requirements', + createdSessionId: null as string | null, + ticket: vi.fn(() => Promise.resolve({ error: null })), + finalize: vi.fn(() => + Promise.resolve({ error: new Error('Cannot finalize sign-up without a created session.') }), + ), + }; + const completedSignUp = { + status: 'missing_requirements', + createdSessionId: null as string | null, + ticket: vi.fn(() => { + client.signUp = { __internal_future: emptySignUp }; + completedSignUp.status = 'complete'; + completedSignUp.createdSessionId = 'sess_123'; + return Promise.resolve({ error: null }); + }), + finalize: vi.fn(() => Promise.resolve({ error: null })), + }; + const client = { + signUp: { __internal_future: completedSignUp }, + }; + const state = { + signUpSignal: () => ({ signUp: completedSignUp }), + }; + const isomorphicClerk = { + loaded: false, + client, + __internal_state: state, + }; + const signUp = new StateProxy(isomorphicClerk as any).signUpSignal().signUp; + + isomorphicClerk.loaded = true; + await signUp.ticket({ ticket: 'ticket_123' }); + + await expect(signUp.finalize()).resolves.toEqual({ error: null }); + expect(signUp.status).toBe('complete'); + expect(signUp.createdSessionId).toBe('sess_123'); + expect(completedSignUp.finalize).toHaveBeenCalledOnce(); + expect(emptySignUp.finalize).not.toHaveBeenCalled(); + }); + + it('falls back to the client sign-in when the state signal is empty', async () => { + const clientSignIn = { + status: 'needs_first_factor', + create: vi.fn(() => Promise.resolve({ error: null })), + }; + const isomorphicClerk = { + loaded: true, + client: { signIn: { __internal_future: clientSignIn } }, + __internal_state: { signInSignal: () => ({ signIn: null }) }, + }; + const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn; + + await expect(signIn.create({ identifier: 'test@example.com' })).resolves.toEqual({ error: null }); + expect(signIn.status).toBe('needs_first_factor'); + expect(clientSignIn.create).toHaveBeenCalledOnce(); + }); + + it('uses a newer sign-in from the state signal instead of the client attempt', async () => { + const clientSignIn = { + finalize: vi.fn(() => Promise.reject(new Error('Finalized the stale client sign-in.'))), + }; + const currentSignIn = { + finalize: vi.fn(() => Promise.resolve({ error: null })), + }; + const isomorphicClerk = { + loaded: true, + client: { signIn: { __internal_future: clientSignIn } }, + __internal_state: { signInSignal: () => ({ signIn: currentSignIn }) }, + }; + const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn; + + await expect(signIn.finalize()).resolves.toEqual({ error: null }); + expect(currentSignIn.finalize).toHaveBeenCalledOnce(); + expect(clientSignIn.finalize).not.toHaveBeenCalled(); + }); + + it('falls back to the fresh client sign-in after the retained state attempt is cleared', async () => { + const clientSignIn = { + status: 'needs_identifier', + }; + let stateSignIn: { status: string; finalize: ReturnType } | null; + const completedSignIn = { + status: 'complete', + finalize: vi.fn(() => { + stateSignIn = null; + return Promise.resolve({ error: null }); + }), + }; + stateSignIn = completedSignIn; + const isomorphicClerk = { + loaded: true, + client: { signIn: { __internal_future: clientSignIn } }, + __internal_state: { signInSignal: () => ({ signIn: stateSignIn }) }, + }; + const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn; + + expect(signIn.status).toBe('complete'); + await expect(signIn.finalize()).resolves.toEqual({ error: null }); + expect(signIn.status).toBe('needs_identifier'); + }); +}); diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index 3066d4e1583..efa9db73f93 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -134,13 +134,15 @@ export class StateProxy implements State { private buildSignInProxy() { const gateProperty = this.gateProperty.bind(this); - const target = () => this.client.signIn.__internal_future; + const target = () => this.state.signInSignal().signIn ?? this.client.signIn.__internal_future; return { errors: defaultSignInErrors(), fetchStatus: 'idle' as const, signIn: { - status: 'needs_identifier' as const, + get status() { + return gateProperty(target, 'status', 'needs_identifier'); + }, availableStrategies: [], get isTransferable() { return gateProperty(target, 'isTransferable', false); @@ -255,7 +257,7 @@ export class StateProxy implements State { private buildSignUpProxy() { const gateProperty = this.gateProperty.bind(this); const gateMethod = this.gateMethod.bind(this); - const target = () => this.client.signUp.__internal_future; + const target = () => this.state.signUpSignal().signUp ?? this.client.signUp.__internal_future; return { errors: defaultSignUpErrors(), From 6e9a6a6c9353e1d7cf5a841b1911f5c0eb31442e Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 1 Sep 2026 02:57:45 +0400 Subject: [PATCH 2/3] test(react): exercise ticket flows before Clerk loads --- .../react/src/__tests__/stateProxy.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/react/src/__tests__/stateProxy.test.ts b/packages/react/src/__tests__/stateProxy.test.ts index 818c442f7a8..2425269c789 100644 --- a/packages/react/src/__tests__/stateProxy.test.ts +++ b/packages/react/src/__tests__/stateProxy.test.ts @@ -27,15 +27,22 @@ describe('StateProxy', () => { const state = { signInSignal: () => ({ signIn: completedSignIn }), }; + const loadedCallbacks: Array<() => void> = []; const isomorphicClerk = { loaded: false, client, __internal_state: state, + addOnLoaded: vi.fn((callback: () => void) => loadedCallbacks.push(callback)), }; const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn; + const ticketPromise = signIn.ticket({ ticket: 'ticket_123' }); + expect(isomorphicClerk.addOnLoaded).toHaveBeenCalledOnce(); + expect(completedSignIn.ticket).not.toHaveBeenCalled(); + isomorphicClerk.loaded = true; - await signIn.ticket({ ticket: 'ticket_123' }); + loadedCallbacks.forEach(callback => callback()); + await ticketPromise; await expect(signIn.finalize()).resolves.toEqual({ error: null }); expect(signIn.status).toBe('complete'); @@ -70,15 +77,22 @@ describe('StateProxy', () => { const state = { signUpSignal: () => ({ signUp: completedSignUp }), }; + const loadedCallbacks: Array<() => void> = []; const isomorphicClerk = { loaded: false, client, __internal_state: state, + addOnLoaded: vi.fn((callback: () => void) => loadedCallbacks.push(callback)), }; const signUp = new StateProxy(isomorphicClerk as any).signUpSignal().signUp; + const ticketPromise = signUp.ticket({ ticket: 'ticket_123' }); + expect(isomorphicClerk.addOnLoaded).toHaveBeenCalledOnce(); + expect(completedSignUp.ticket).not.toHaveBeenCalled(); + isomorphicClerk.loaded = true; - await signUp.ticket({ ticket: 'ticket_123' }); + loadedCallbacks.forEach(callback => callback()); + await ticketPromise; await expect(signUp.finalize()).resolves.toEqual({ error: null }); expect(signUp.status).toBe('complete'); From c2ea4d4f92757ed892a7b2c81af4b822e40327e1 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 1 Sep 2026 03:07:53 +0400 Subject: [PATCH 3/3] test(react): fix state proxy fixture types --- packages/react/src/__tests__/stateProxy.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/react/src/__tests__/stateProxy.test.ts b/packages/react/src/__tests__/stateProxy.test.ts index 2425269c789..28e3f366619 100644 --- a/packages/react/src/__tests__/stateProxy.test.ts +++ b/packages/react/src/__tests__/stateProxy.test.ts @@ -1,3 +1,4 @@ +import type { SignInFutureResource, SignUpFutureResource } from '@clerk/shared/types'; import { describe, expect, it, vi } from 'vitest'; import { StateProxy } from '../stateProxy'; @@ -34,7 +35,7 @@ describe('StateProxy', () => { __internal_state: state, addOnLoaded: vi.fn((callback: () => void) => loadedCallbacks.push(callback)), }; - const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn; + const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn as SignInFutureResource; const ticketPromise = signIn.ticket({ ticket: 'ticket_123' }); expect(isomorphicClerk.addOnLoaded).toHaveBeenCalledOnce(); @@ -71,7 +72,9 @@ describe('StateProxy', () => { }), finalize: vi.fn(() => Promise.resolve({ error: null })), }; - const client = { + const client: { + signUp: { __internal_future: typeof completedSignUp | typeof emptySignUp }; + } = { signUp: { __internal_future: completedSignUp }, }; const state = { @@ -84,7 +87,7 @@ describe('StateProxy', () => { __internal_state: state, addOnLoaded: vi.fn((callback: () => void) => loadedCallbacks.push(callback)), }; - const signUp = new StateProxy(isomorphicClerk as any).signUpSignal().signUp; + const signUp = new StateProxy(isomorphicClerk as any).signUpSignal().signUp as SignUpFutureResource; const ticketPromise = signUp.ticket({ ticket: 'ticket_123' }); expect(isomorphicClerk.addOnLoaded).toHaveBeenCalledOnce(); @@ -111,7 +114,7 @@ describe('StateProxy', () => { client: { signIn: { __internal_future: clientSignIn } }, __internal_state: { signInSignal: () => ({ signIn: null }) }, }; - const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn; + const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn as SignInFutureResource; await expect(signIn.create({ identifier: 'test@example.com' })).resolves.toEqual({ error: null }); expect(signIn.status).toBe('needs_first_factor'); @@ -130,7 +133,7 @@ describe('StateProxy', () => { client: { signIn: { __internal_future: clientSignIn } }, __internal_state: { signInSignal: () => ({ signIn: currentSignIn }) }, }; - const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn; + const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn as SignInFutureResource; await expect(signIn.finalize()).resolves.toEqual({ error: null }); expect(currentSignIn.finalize).toHaveBeenCalledOnce(); @@ -155,7 +158,7 @@ describe('StateProxy', () => { client: { signIn: { __internal_future: clientSignIn } }, __internal_state: { signInSignal: () => ({ signIn: stateSignIn }) }, }; - const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn; + const signIn = new StateProxy(isomorphicClerk as any).signInSignal().signIn as SignInFutureResource; expect(signIn.status).toBe('complete'); await expect(signIn.finalize()).resolves.toEqual({ error: null });