Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fiery-games-pick.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix inconsistent `fetchStatus` during sign-in and sign-up flows where the resource would briefly show as `complete` while `fetchStatus` was still `fetching`
134 changes: 114 additions & 20 deletions packages/clerk-js/src/core/__tests__/state.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,74 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { eventBus } from '../events';
import { SignIn } from '../resources/SignIn';
import { SignUp } from '../resources/SignUp';
import { signInResourceSignal, signUpResourceSignal } from '../signals';
import { signInFetchSignal, signInResourceSignal, signUpResourceSignal } from '../signals';
import { State } from '../state';

describe('Signal batching', () => {
let state: State;

beforeEach(() => {
signInResourceSignal({ resource: null });
signInFetchSignal({ status: 'idle' });
state = new State();
});

it('should produce at most 3 renders with clean fetchStatus transitions during an API call', async () => {
const signIn = new SignIn(null);
const snapshots: Array<{ fetchStatus: string; hasSignIn: boolean }> = [];

state.__internal_effect(() => {
const s = state.signInSignal();
snapshots.push({ fetchStatus: s.fetchStatus, hasSignIn: s.signIn !== null });
});

await signIn.__internal_future.password({ password: 'test123', identifier: 'test@example.com' }).catch(() => {
// Expected to fail since there's no real API
});

expect(snapshots.length).toBeLessThanOrEqual(3);

// fetchStatus follows a clean idle → fetching → idle progression
const transitions = snapshots.map(s => s.fetchStatus).filter((s, i, arr) => i === 0 || s !== arr[i - 1]);
expect(transitions).toEqual(['idle', 'fetching', 'idle']);
});

it('should skip resource-only updates while fetching and apply them on idle', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

// Simulate fetchStatus: fetching (as runAsyncResourceTask would)
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Simulate fromJSON updating the resource mid-flight (resource-only event)
eventBus.emit('resource:state-change', { resource: signIn });

// Resource signal should NOT have been updated — skipped while fetching
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Simulate task completion — resource is carried again with fetchStatus: idle
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'idle' });

// Now both resource and fetchStatus are consistent
expect(signInFetchSignal().status).toBe('idle');
expect(signInResourceSignal().resource).toBe(signIn);
});

it('should reflect new resource data immediately when no operation is in flight', () => {
let latestSignInId: string | undefined;

state.__internal_effect(() => {
latestSignInId = state.signInSignal().signIn?.id;
});

expect(latestSignInId).toBeUndefined();

new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

expect(latestSignInId).toBe('signin_123');
});
});

describe('State', () => {
let _state: State;

Expand DownExpand Up@@ -52,7 +117,7 @@ describe('State', () => {
expect(existingSignUp.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignUp (simulating client refresh with null sign_up)
const _nullSignUp = new SignUp(null);
new SignUp(null);

// Assert: Signal should NOT be updated - should still have the existing SignUp
expect(signUpResourceSignal().resource).toBe(existingSignUp);
Expand DownExpand Up@@ -96,11 +161,6 @@ describe('State', () => {
resetSignUp: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignUpFromReset = new SignUp(null);
this.signUp = newSignUpFromReset;
// reset() emits resource:error to clear errors, but the signal update
// happens via resource:update when the new SignUp is created
eventBus.emit('resource:error', { resource: newSignUpFromReset, error: null });
// Emit resource:update to update the signal (simulating what happens in real flow)
eventBus.emit('resource:update', { resource: newSignUpFromReset });
}),
};
SignUp.clerk = { client: mockClient } as any;
Expand All@@ -127,10 +187,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignUp with id
const _existingSignUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Act: Emit a resource update with a different SignUp that also has an id
// Act: Create a different SignUp that also has an id
const newSignUp = new SignUp({ id: 'signup_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignUp
Expand DownExpand Up@@ -159,7 +219,7 @@ describe('State', () => {
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignIn (simulating client refresh with null sign_in)
const _nullSignIn = new SignIn(null);
new SignIn(null);

// Assert: Signal should NOT be updated - should still have the existing SignIn
expect(signInResourceSignal().resource).toBe(existingSignIn);
Expand DownExpand Up@@ -201,16 +261,13 @@ describe('State', () => {
resetSignIn: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignInFromReset = new SignIn(null);
this.signIn = newSignInFromReset;
eventBus.emit('resource:error', { resource: newSignInFromReset, error: null });
eventBus.emit('resource:update', { resource: newSignInFromReset });
}),
};
SignIn.clerk = { client: mockClient } as any;

// Create a SignIn with id
const existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Call reset()
await existingSignIn.__internal_future.reset();
Expand All@@ -224,10 +281,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignIn with id
const _existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Act: Emit a resource update with a different SignIn that also has an id
// Act: Create a different SignIn that also has an id
const newSignIn = new SignIn({ id: 'signin_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignIn
Expand All@@ -240,19 +297,19 @@ describe('State', () => {
describe('Edge cases', () => {
it('should handle rapid successive updates correctly', () => {
// First update with valid SignUp
const _signUp1 = new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_1');

// Second update with another valid SignUp
const _signUp2 = new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Null update should be ignored
const _nullSignUp = new SignUp(null);
new SignUp(null);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Another valid update should work
const _signUp3 = new SignUp({ id: 'signup_3', status: 'complete' } as any);
new SignUp({ id: 'signup_3', status: 'complete' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_3');
});

Expand All@@ -262,10 +319,47 @@ describe('State', () => {
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Manually emit update with the same instance (simulating fromJSON on same instance)
eventBus.emit('resource:update', { resource: signUp });
eventBus.emit('resource:state-change', { resource: signUp });

// Signal should still have the same instance
expect(signUpResourceSignal().resource).toBe(signUp);
});
});

describe('Client.destroy()', () => {
it('should update signals when resources are replaced with null instances', async () => {
const mockSetActive = vi.fn().mockResolvedValue({});
SignIn.clerk = { setActive: mockSetActive, client: { sessions: [{ id: 'session_123' }] } } as any;

const existingSignIn = new SignIn({
id: 'signin_123',
status: 'complete',
created_session_id: 'session_123',
} as any);
expect(signInResourceSignal().resource).toBe(existingSignIn);

await existingSignIn.__internal_future.finalize();
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(true);

// Simulates what Client.destroy() does — creating a null resource replaces the existing one
const nullSignIn = new SignIn(null);

expect(signInResourceSignal().resource).toBe(nullSignIn);
expect(signInResourceSignal().resource?.id).toBeUndefined();
});
});

describe('fetchStatus clearing on reset', () => {
it('should clear fetchStatus to idle when resource is reset during an in-flight fetch', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Reset replaces the resource and clears fetchStatus in one event
const nullSignIn = new SignIn(null);
eventBus.emit('resource:state-change', { resource: nullSignIn, error: null, fetchStatus: 'idle' });

expect(signInFetchSignal().status).toBe('idle');
});
});
});
16 changes: 7 additions & 9 deletions packages/clerk-js/src/core/events.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,24 +9,22 @@ export const events = {
UserSignOut: 'user:signOut',
EnvironmentUpdate: 'environment:update',
SessionTokenResolved: 'session:tokenResolved',
ResourceUpdate: 'resource:update',
ResourceError: 'resource:error',
ResourceFetch: 'resource:fetch',
ResourceStateChange: 'resource:state-change',
} as const;

type TokenUpdatePayload = { token: TokenResource | null };
export type ResourceUpdatePayload = { resource: BaseResource };
export type ResourceErrorPayload = { resource: BaseResource; error: ClerkError | null };
export type ResourceFetchPayload = { resource: BaseResource; status: 'idle' | 'fetching' };
export type ResourceStateChangePayload = {
resource: BaseResource;
error?: ClerkError | null;
fetchStatus?: 'idle' | 'fetching';
};

type InternalEvents = {
[events.TokenUpdate]: TokenUpdatePayload;
[events.UserSignOut]: null;
[events.EnvironmentUpdate]: null;
[events.SessionTokenResolved]: null;
[events.ResourceUpdate]: ResourceUpdatePayload;
[events.ResourceError]: ResourceErrorPayload;
[events.ResourceFetch]: ResourceFetchPayload;
[events.ResourceStateChange]: ResourceStateChangePayload;
};

export const eventBus = createEventBus<InternalEvents>();
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,13 +106,13 @@ export class Client extends BaseResource implements ClientResource {
resetSignIn(): void {
this.signIn = new SignIn(null);
// Cast needed because this.signIn is typed as SignInResource (interface), not SignIn (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signIn as SignIn, error: null });
eventBus.emit('resource:state-change', { resource: this.signIn as SignIn, error: null, fetchStatus: 'idle' });
}

resetSignUp(): void {
this.signUp = new SignUp(null);
// Cast needed because this.signUp is typed as SignUpResource (interface), not SignUp (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signUp as SignUp, error: null });
eventBus.emit('resource:state-change', { resource: this.signUp as SignUp, error: null, fetchStatus: 'idle' });
}

clearCache(): void {
Expand Down
16 changes: 12 additions & 4 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -596,7 +596,7 @@ export class SignIn extends BaseResource implements SignInResource {
this.clientTrustState = data.client_trust_state ?? undefined;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1417,14 +1417,19 @@ class SignInFuture implements SignInFutureResource {
return this.create({ ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-in by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-in
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignInFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};

if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-in without a created session.');
}

return runAsyncResourceTask(this.#resource, async () => {
try {
// Reload the client if the created session is not in the client's sessions. This can happen during modal SSO
// flows where the in-memory client does not have the created session.
if (SignIn.clerk.client && !SignIn.clerk.client.sessions.some(s => s.id === this.#resource.createdSessionId)) {
Expand All@@ -1433,12 +1438,15 @@ class SignInFuture implements SignInFutureResource {

this.#canBeDiscarded = true;
await SignIn.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-in attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
18 changes: 13 additions & 5 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -510,7 +510,7 @@ export class SignUp extends BaseResource implements SignUpResource {
this.locale = data.locale;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1138,21 +1138,29 @@ class SignUpFuture implements SignUpFutureResource {
return this.create({ ...params, ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-up by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-up
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignUpFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};
return runAsyncResourceTask(this.#resource, async () => {

try {
if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-up without a created session.');
}

this.#canBeDiscarded = true;
await SignUp.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-up attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Waitlist.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,13 +27,13 @@ export class Waitlist extends BaseResource implements WaitlistResource {
this.updatedAt = unixEpochToDate(data.updated_at);
this.createdAt = unixEpochToDate(data.created_at);

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

async join(params: JoinWaitlistParams): Promise<{ error: ClerkError | null }> {
return runAsyncResourceTask(this, async () => {
await Waitlist.join(params);
await this._basePost({ path: this.pathRoot, body: params });
});
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fiery-games-pick.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix inconsistent `fetchStatus` during sign-in and sign-up flows where the resource would briefly show as `complete` while `fetchStatus` was still `fetching`
134 changes: 114 additions & 20 deletions packages/clerk-js/src/core/__tests__/state.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,74 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { eventBus } from '../events';
import { SignIn } from '../resources/SignIn';
import { SignUp } from '../resources/SignUp';
import { signInResourceSignal, signUpResourceSignal } from '../signals';
import { signInFetchSignal, signInResourceSignal, signUpResourceSignal } from '../signals';
import { State } from '../state';

describe('Signal batching', () => {
let state: State;

beforeEach(() => {
signInResourceSignal({ resource: null });
signInFetchSignal({ status: 'idle' });
state = new State();
});

it('should produce at most 3 renders with clean fetchStatus transitions during an API call', async () => {
const signIn = new SignIn(null);
const snapshots: Array<{ fetchStatus: string; hasSignIn: boolean }> = [];

state.__internal_effect(() => {
const s = state.signInSignal();
snapshots.push({ fetchStatus: s.fetchStatus, hasSignIn: s.signIn !== null });
});

await signIn.__internal_future.password({ password: 'test123', identifier: 'test@example.com' }).catch(() => {
// Expected to fail since there's no real API
});

expect(snapshots.length).toBeLessThanOrEqual(3);

// fetchStatus follows a clean idle → fetching → idle progression
const transitions = snapshots.map(s => s.fetchStatus).filter((s, i, arr) => i === 0 || s !== arr[i - 1]);
expect(transitions).toEqual(['idle', 'fetching', 'idle']);
});

it('should skip resource-only updates while fetching and apply them on idle', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

// Simulate fetchStatus: fetching (as runAsyncResourceTask would)
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Simulate fromJSON updating the resource mid-flight (resource-only event)
eventBus.emit('resource:state-change', { resource: signIn });

// Resource signal should NOT have been updated — skipped while fetching
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Simulate task completion — resource is carried again with fetchStatus: idle
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'idle' });

// Now both resource and fetchStatus are consistent
expect(signInFetchSignal().status).toBe('idle');
expect(signInResourceSignal().resource).toBe(signIn);
});

it('should reflect new resource data immediately when no operation is in flight', () => {
let latestSignInId: string | undefined;

state.__internal_effect(() => {
latestSignInId = state.signInSignal().signIn?.id;
});

expect(latestSignInId).toBeUndefined();

new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

expect(latestSignInId).toBe('signin_123');
});
});

describe('State', () => {
let _state: State;

Expand DownExpand Up@@ -52,7 +117,7 @@ describe('State', () => {
expect(existingSignUp.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignUp (simulating client refresh with null sign_up)
const _nullSignUp = new SignUp(null);
new SignUp(null);

// Assert: Signal should NOT be updated - should still have the existing SignUp
expect(signUpResourceSignal().resource).toBe(existingSignUp);
Expand DownExpand Up@@ -96,11 +161,6 @@ describe('State', () => {
resetSignUp: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignUpFromReset = new SignUp(null);
this.signUp = newSignUpFromReset;
// reset() emits resource:error to clear errors, but the signal update
// happens via resource:update when the new SignUp is created
eventBus.emit('resource:error', { resource: newSignUpFromReset, error: null });
// Emit resource:update to update the signal (simulating what happens in real flow)
eventBus.emit('resource:update', { resource: newSignUpFromReset });
}),
};
SignUp.clerk = { client: mockClient } as any;
Expand All@@ -127,10 +187,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignUp with id
const _existingSignUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Act: Emit a resource update with a different SignUp that also has an id
// Act: Create a different SignUp that also has an id
const newSignUp = new SignUp({ id: 'signup_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignUp
Expand DownExpand Up@@ -159,7 +219,7 @@ describe('State', () => {
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignIn (simulating client refresh with null sign_in)
const _nullSignIn = new SignIn(null);
new SignIn(null);

// Assert: Signal should NOT be updated - should still have the existing SignIn
expect(signInResourceSignal().resource).toBe(existingSignIn);
Expand DownExpand Up@@ -201,16 +261,13 @@ describe('State', () => {
resetSignIn: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignInFromReset = new SignIn(null);
this.signIn = newSignInFromReset;
eventBus.emit('resource:error', { resource: newSignInFromReset, error: null });
eventBus.emit('resource:update', { resource: newSignInFromReset });
}),
};
SignIn.clerk = { client: mockClient } as any;

// Create a SignIn with id
const existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Call reset()
await existingSignIn.__internal_future.reset();
Expand All@@ -224,10 +281,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignIn with id
const _existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Act: Emit a resource update with a different SignIn that also has an id
// Act: Create a different SignIn that also has an id
const newSignIn = new SignIn({ id: 'signin_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignIn
Expand All@@ -240,19 +297,19 @@ describe('State', () => {
describe('Edge cases', () => {
it('should handle rapid successive updates correctly', () => {
// First update with valid SignUp
const _signUp1 = new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_1');

// Second update with another valid SignUp
const _signUp2 = new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Null update should be ignored
const _nullSignUp = new SignUp(null);
new SignUp(null);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Another valid update should work
const _signUp3 = new SignUp({ id: 'signup_3', status: 'complete' } as any);
new SignUp({ id: 'signup_3', status: 'complete' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_3');
});

Expand All@@ -262,10 +319,47 @@ describe('State', () => {
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Manually emit update with the same instance (simulating fromJSON on same instance)
eventBus.emit('resource:update', { resource: signUp });
eventBus.emit('resource:state-change', { resource: signUp });

// Signal should still have the same instance
expect(signUpResourceSignal().resource).toBe(signUp);
});
});

describe('Client.destroy()', () => {
it('should update signals when resources are replaced with null instances', async () => {
const mockSetActive = vi.fn().mockResolvedValue({});
SignIn.clerk = { setActive: mockSetActive, client: { sessions: [{ id: 'session_123' }] } } as any;

const existingSignIn = new SignIn({
id: 'signin_123',
status: 'complete',
created_session_id: 'session_123',
} as any);
expect(signInResourceSignal().resource).toBe(existingSignIn);

await existingSignIn.__internal_future.finalize();
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(true);

// Simulates what Client.destroy() does — creating a null resource replaces the existing one
const nullSignIn = new SignIn(null);

expect(signInResourceSignal().resource).toBe(nullSignIn);
expect(signInResourceSignal().resource?.id).toBeUndefined();
});
});

describe('fetchStatus clearing on reset', () => {
it('should clear fetchStatus to idle when resource is reset during an in-flight fetch', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Reset replaces the resource and clears fetchStatus in one event
const nullSignIn = new SignIn(null);
eventBus.emit('resource:state-change', { resource: nullSignIn, error: null, fetchStatus: 'idle' });

expect(signInFetchSignal().status).toBe('idle');
});
});
});
16 changes: 7 additions & 9 deletions packages/clerk-js/src/core/events.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,24 +9,22 @@ export const events = {
UserSignOut: 'user:signOut',
EnvironmentUpdate: 'environment:update',
SessionTokenResolved: 'session:tokenResolved',
ResourceUpdate: 'resource:update',
ResourceError: 'resource:error',
ResourceFetch: 'resource:fetch',
ResourceStateChange: 'resource:state-change',
} as const;

type TokenUpdatePayload = { token: TokenResource | null };
export type ResourceUpdatePayload = { resource: BaseResource };
export type ResourceErrorPayload = { resource: BaseResource; error: ClerkError | null };
export type ResourceFetchPayload = { resource: BaseResource; status: 'idle' | 'fetching' };
export type ResourceStateChangePayload = {
resource: BaseResource;
error?: ClerkError | null;
fetchStatus?: 'idle' | 'fetching';
};

type InternalEvents = {
[events.TokenUpdate]: TokenUpdatePayload;
[events.UserSignOut]: null;
[events.EnvironmentUpdate]: null;
[events.SessionTokenResolved]: null;
[events.ResourceUpdate]: ResourceUpdatePayload;
[events.ResourceError]: ResourceErrorPayload;
[events.ResourceFetch]: ResourceFetchPayload;
[events.ResourceStateChange]: ResourceStateChangePayload;
};

export const eventBus = createEventBus<InternalEvents>();
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,13 +106,13 @@ export class Client extends BaseResource implements ClientResource {
resetSignIn(): void {
this.signIn = new SignIn(null);
// Cast needed because this.signIn is typed as SignInResource (interface), not SignIn (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signIn as SignIn, error: null });
eventBus.emit('resource:state-change', { resource: this.signIn as SignIn, error: null, fetchStatus: 'idle' });
}

resetSignUp(): void {
this.signUp = new SignUp(null);
// Cast needed because this.signUp is typed as SignUpResource (interface), not SignUp (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signUp as SignUp, error: null });
eventBus.emit('resource:state-change', { resource: this.signUp as SignUp, error: null, fetchStatus: 'idle' });
}

clearCache(): void {
Expand Down
16 changes: 12 additions & 4 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -596,7 +596,7 @@ export class SignIn extends BaseResource implements SignInResource {
this.clientTrustState = data.client_trust_state ?? undefined;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1417,14 +1417,19 @@ class SignInFuture implements SignInFutureResource {
return this.create({ ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-in by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-in
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignInFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};

if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-in without a created session.');
}

return runAsyncResourceTask(this.#resource, async () => {
try {
// Reload the client if the created session is not in the client's sessions. This can happen during modal SSO
// flows where the in-memory client does not have the created session.
if (SignIn.clerk.client && !SignIn.clerk.client.sessions.some(s => s.id === this.#resource.createdSessionId)) {
Expand All@@ -1433,12 +1438,15 @@ class SignInFuture implements SignInFutureResource {

this.#canBeDiscarded = true;
await SignIn.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-in attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
18 changes: 13 additions & 5 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -510,7 +510,7 @@ export class SignUp extends BaseResource implements SignUpResource {
this.locale = data.locale;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1138,21 +1138,29 @@ class SignUpFuture implements SignUpFutureResource {
return this.create({ ...params, ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-up by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-up
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignUpFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};
return runAsyncResourceTask(this.#resource, async () => {

try {
if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-up without a created session.');
}

this.#canBeDiscarded = true;
await SignUp.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-up attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Waitlist.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,13 +27,13 @@ export class Waitlist extends BaseResource implements WaitlistResource {
this.updatedAt = unixEpochToDate(data.updated_at);
this.createdAt = unixEpochToDate(data.created_at);

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

async join(params: JoinWaitlistParams): Promise<{ error: ClerkError | null }> {
return runAsyncResourceTask(this, async () => {
await Waitlist.join(params);
await this._basePost({ path: this.pathRoot, body: params });
});
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fiery-games-pick.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix inconsistent `fetchStatus` during sign-in and sign-up flows where the resource would briefly show as `complete` while `fetchStatus` was still `fetching`
134 changes: 114 additions & 20 deletions packages/clerk-js/src/core/__tests__/state.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,74 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { eventBus } from '../events';
import { SignIn } from '../resources/SignIn';
import { SignUp } from '../resources/SignUp';
import { signInResourceSignal, signUpResourceSignal } from '../signals';
import { signInFetchSignal, signInResourceSignal, signUpResourceSignal } from '../signals';
import { State } from '../state';

describe('Signal batching', () => {
let state: State;

beforeEach(() => {
signInResourceSignal({ resource: null });
signInFetchSignal({ status: 'idle' });
state = new State();
});

it('should produce at most 3 renders with clean fetchStatus transitions during an API call', async () => {
const signIn = new SignIn(null);
const snapshots: Array<{ fetchStatus: string; hasSignIn: boolean }> = [];

state.__internal_effect(() => {
const s = state.signInSignal();
snapshots.push({ fetchStatus: s.fetchStatus, hasSignIn: s.signIn !== null });
});

await signIn.__internal_future.password({ password: 'test123', identifier: 'test@example.com' }).catch(() => {
// Expected to fail since there's no real API
});

expect(snapshots.length).toBeLessThanOrEqual(3);

// fetchStatus follows a clean idle → fetching → idle progression
const transitions = snapshots.map(s => s.fetchStatus).filter((s, i, arr) => i === 0 || s !== arr[i - 1]);
expect(transitions).toEqual(['idle', 'fetching', 'idle']);
});

it('should skip resource-only updates while fetching and apply them on idle', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

// Simulate fetchStatus: fetching (as runAsyncResourceTask would)
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Simulate fromJSON updating the resource mid-flight (resource-only event)
eventBus.emit('resource:state-change', { resource: signIn });

// Resource signal should NOT have been updated — skipped while fetching
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Simulate task completion — resource is carried again with fetchStatus: idle
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'idle' });

// Now both resource and fetchStatus are consistent
expect(signInFetchSignal().status).toBe('idle');
expect(signInResourceSignal().resource).toBe(signIn);
});

it('should reflect new resource data immediately when no operation is in flight', () => {
let latestSignInId: string | undefined;

state.__internal_effect(() => {
latestSignInId = state.signInSignal().signIn?.id;
});

expect(latestSignInId).toBeUndefined();

new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

expect(latestSignInId).toBe('signin_123');
});
});

describe('State', () => {
let _state: State;

Expand DownExpand Up@@ -52,7 +117,7 @@ describe('State', () => {
expect(existingSignUp.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignUp (simulating client refresh with null sign_up)
const _nullSignUp = new SignUp(null);
new SignUp(null);

// Assert: Signal should NOT be updated - should still have the existing SignUp
expect(signUpResourceSignal().resource).toBe(existingSignUp);
Expand DownExpand Up@@ -96,11 +161,6 @@ describe('State', () => {
resetSignUp: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignUpFromReset = new SignUp(null);
this.signUp = newSignUpFromReset;
// reset() emits resource:error to clear errors, but the signal update
// happens via resource:update when the new SignUp is created
eventBus.emit('resource:error', { resource: newSignUpFromReset, error: null });
// Emit resource:update to update the signal (simulating what happens in real flow)
eventBus.emit('resource:update', { resource: newSignUpFromReset });
}),
};
SignUp.clerk = { client: mockClient } as any;
Expand All@@ -127,10 +187,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignUp with id
const _existingSignUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Act: Emit a resource update with a different SignUp that also has an id
// Act: Create a different SignUp that also has an id
const newSignUp = new SignUp({ id: 'signup_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignUp
Expand DownExpand Up@@ -159,7 +219,7 @@ describe('State', () => {
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignIn (simulating client refresh with null sign_in)
const _nullSignIn = new SignIn(null);
new SignIn(null);

// Assert: Signal should NOT be updated - should still have the existing SignIn
expect(signInResourceSignal().resource).toBe(existingSignIn);
Expand DownExpand Up@@ -201,16 +261,13 @@ describe('State', () => {
resetSignIn: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignInFromReset = new SignIn(null);
this.signIn = newSignInFromReset;
eventBus.emit('resource:error', { resource: newSignInFromReset, error: null });
eventBus.emit('resource:update', { resource: newSignInFromReset });
}),
};
SignIn.clerk = { client: mockClient } as any;

// Create a SignIn with id
const existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Call reset()
await existingSignIn.__internal_future.reset();
Expand All@@ -224,10 +281,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignIn with id
const _existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Act: Emit a resource update with a different SignIn that also has an id
// Act: Create a different SignIn that also has an id
const newSignIn = new SignIn({ id: 'signin_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignIn
Expand All@@ -240,19 +297,19 @@ describe('State', () => {
describe('Edge cases', () => {
it('should handle rapid successive updates correctly', () => {
// First update with valid SignUp
const _signUp1 = new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_1');

// Second update with another valid SignUp
const _signUp2 = new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Null update should be ignored
const _nullSignUp = new SignUp(null);
new SignUp(null);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Another valid update should work
const _signUp3 = new SignUp({ id: 'signup_3', status: 'complete' } as any);
new SignUp({ id: 'signup_3', status: 'complete' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_3');
});

Expand All@@ -262,10 +319,47 @@ describe('State', () => {
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Manually emit update with the same instance (simulating fromJSON on same instance)
eventBus.emit('resource:update', { resource: signUp });
eventBus.emit('resource:state-change', { resource: signUp });

// Signal should still have the same instance
expect(signUpResourceSignal().resource).toBe(signUp);
});
});

describe('Client.destroy()', () => {
it('should update signals when resources are replaced with null instances', async () => {
const mockSetActive = vi.fn().mockResolvedValue({});
SignIn.clerk = { setActive: mockSetActive, client: { sessions: [{ id: 'session_123' }] } } as any;

const existingSignIn = new SignIn({
id: 'signin_123',
status: 'complete',
created_session_id: 'session_123',
} as any);
expect(signInResourceSignal().resource).toBe(existingSignIn);

await existingSignIn.__internal_future.finalize();
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(true);

// Simulates what Client.destroy() does — creating a null resource replaces the existing one
const nullSignIn = new SignIn(null);

expect(signInResourceSignal().resource).toBe(nullSignIn);
expect(signInResourceSignal().resource?.id).toBeUndefined();
});
});

describe('fetchStatus clearing on reset', () => {
it('should clear fetchStatus to idle when resource is reset during an in-flight fetch', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Reset replaces the resource and clears fetchStatus in one event
const nullSignIn = new SignIn(null);
eventBus.emit('resource:state-change', { resource: nullSignIn, error: null, fetchStatus: 'idle' });

expect(signInFetchSignal().status).toBe('idle');
});
});
});
16 changes: 7 additions & 9 deletions packages/clerk-js/src/core/events.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,24 +9,22 @@ export const events = {
UserSignOut: 'user:signOut',
EnvironmentUpdate: 'environment:update',
SessionTokenResolved: 'session:tokenResolved',
ResourceUpdate: 'resource:update',
ResourceError: 'resource:error',
ResourceFetch: 'resource:fetch',
ResourceStateChange: 'resource:state-change',
} as const;

type TokenUpdatePayload = { token: TokenResource | null };
export type ResourceUpdatePayload = { resource: BaseResource };
export type ResourceErrorPayload = { resource: BaseResource; error: ClerkError | null };
export type ResourceFetchPayload = { resource: BaseResource; status: 'idle' | 'fetching' };
export type ResourceStateChangePayload = {
resource: BaseResource;
error?: ClerkError | null;
fetchStatus?: 'idle' | 'fetching';
};

type InternalEvents = {
[events.TokenUpdate]: TokenUpdatePayload;
[events.UserSignOut]: null;
[events.EnvironmentUpdate]: null;
[events.SessionTokenResolved]: null;
[events.ResourceUpdate]: ResourceUpdatePayload;
[events.ResourceError]: ResourceErrorPayload;
[events.ResourceFetch]: ResourceFetchPayload;
[events.ResourceStateChange]: ResourceStateChangePayload;
};

export const eventBus = createEventBus<InternalEvents>();
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,13 +106,13 @@ export class Client extends BaseResource implements ClientResource {
resetSignIn(): void {
this.signIn = new SignIn(null);
// Cast needed because this.signIn is typed as SignInResource (interface), not SignIn (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signIn as SignIn, error: null });
eventBus.emit('resource:state-change', { resource: this.signIn as SignIn, error: null, fetchStatus: 'idle' });
}

resetSignUp(): void {
this.signUp = new SignUp(null);
// Cast needed because this.signUp is typed as SignUpResource (interface), not SignUp (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signUp as SignUp, error: null });
eventBus.emit('resource:state-change', { resource: this.signUp as SignUp, error: null, fetchStatus: 'idle' });
}

clearCache(): void {
Expand Down
16 changes: 12 additions & 4 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -596,7 +596,7 @@ export class SignIn extends BaseResource implements SignInResource {
this.clientTrustState = data.client_trust_state ?? undefined;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1417,14 +1417,19 @@ class SignInFuture implements SignInFutureResource {
return this.create({ ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-in by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-in
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignInFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};

if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-in without a created session.');
}

return runAsyncResourceTask(this.#resource, async () => {
try {
// Reload the client if the created session is not in the client's sessions. This can happen during modal SSO
// flows where the in-memory client does not have the created session.
if (SignIn.clerk.client && !SignIn.clerk.client.sessions.some(s => s.id === this.#resource.createdSessionId)) {
Expand All@@ -1433,12 +1438,15 @@ class SignInFuture implements SignInFutureResource {

this.#canBeDiscarded = true;
await SignIn.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-in attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
18 changes: 13 additions & 5 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -510,7 +510,7 @@ export class SignUp extends BaseResource implements SignUpResource {
this.locale = data.locale;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1138,21 +1138,29 @@ class SignUpFuture implements SignUpFutureResource {
return this.create({ ...params, ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-up by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-up
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignUpFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};
return runAsyncResourceTask(this.#resource, async () => {

try {
if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-up without a created session.');
}

this.#canBeDiscarded = true;
await SignUp.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-up attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Waitlist.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,13 +27,13 @@ export class Waitlist extends BaseResource implements WaitlistResource {
this.updatedAt = unixEpochToDate(data.updated_at);
this.createdAt = unixEpochToDate(data.created_at);

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

async join(params: JoinWaitlistParams): Promise<{ error: ClerkError | null }> {
return runAsyncResourceTask(this, async () => {
await Waitlist.join(params);
await this._basePost({ path: this.pathRoot, body: params });
});
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fiery-games-pick.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix inconsistent `fetchStatus` during sign-in and sign-up flows where the resource would briefly show as `complete` while `fetchStatus` was still `fetching`
134 changes: 114 additions & 20 deletions packages/clerk-js/src/core/__tests__/state.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,74 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { eventBus } from '../events';
import { SignIn } from '../resources/SignIn';
import { SignUp } from '../resources/SignUp';
import { signInResourceSignal, signUpResourceSignal } from '../signals';
import { signInFetchSignal, signInResourceSignal, signUpResourceSignal } from '../signals';
import { State } from '../state';

describe('Signal batching', () => {
let state: State;

beforeEach(() => {
signInResourceSignal({ resource: null });
signInFetchSignal({ status: 'idle' });
state = new State();
});

it('should produce at most 3 renders with clean fetchStatus transitions during an API call', async () => {
const signIn = new SignIn(null);
const snapshots: Array<{ fetchStatus: string; hasSignIn: boolean }> = [];

state.__internal_effect(() => {
const s = state.signInSignal();
snapshots.push({ fetchStatus: s.fetchStatus, hasSignIn: s.signIn !== null });
});

await signIn.__internal_future.password({ password: 'test123', identifier: 'test@example.com' }).catch(() => {
// Expected to fail since there's no real API
});

expect(snapshots.length).toBeLessThanOrEqual(3);

// fetchStatus follows a clean idle → fetching → idle progression
const transitions = snapshots.map(s => s.fetchStatus).filter((s, i, arr) => i === 0 || s !== arr[i - 1]);
expect(transitions).toEqual(['idle', 'fetching', 'idle']);
});

it('should skip resource-only updates while fetching and apply them on idle', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

// Simulate fetchStatus: fetching (as runAsyncResourceTask would)
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Simulate fromJSON updating the resource mid-flight (resource-only event)
eventBus.emit('resource:state-change', { resource: signIn });

// Resource signal should NOT have been updated — skipped while fetching
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Simulate task completion — resource is carried again with fetchStatus: idle
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'idle' });

// Now both resource and fetchStatus are consistent
expect(signInFetchSignal().status).toBe('idle');
expect(signInResourceSignal().resource).toBe(signIn);
});

it('should reflect new resource data immediately when no operation is in flight', () => {
let latestSignInId: string | undefined;

state.__internal_effect(() => {
latestSignInId = state.signInSignal().signIn?.id;
});

expect(latestSignInId).toBeUndefined();

new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

expect(latestSignInId).toBe('signin_123');
});
});

describe('State', () => {
let _state: State;

Expand DownExpand Up@@ -52,7 +117,7 @@ describe('State', () => {
expect(existingSignUp.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignUp (simulating client refresh with null sign_up)
const _nullSignUp = new SignUp(null);
new SignUp(null);

// Assert: Signal should NOT be updated - should still have the existing SignUp
expect(signUpResourceSignal().resource).toBe(existingSignUp);
Expand DownExpand Up@@ -96,11 +161,6 @@ describe('State', () => {
resetSignUp: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignUpFromReset = new SignUp(null);
this.signUp = newSignUpFromReset;
// reset() emits resource:error to clear errors, but the signal update
// happens via resource:update when the new SignUp is created
eventBus.emit('resource:error', { resource: newSignUpFromReset, error: null });
// Emit resource:update to update the signal (simulating what happens in real flow)
eventBus.emit('resource:update', { resource: newSignUpFromReset });
}),
};
SignUp.clerk = { client: mockClient } as any;
Expand All@@ -127,10 +187,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignUp with id
const _existingSignUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Act: Emit a resource update with a different SignUp that also has an id
// Act: Create a different SignUp that also has an id
const newSignUp = new SignUp({ id: 'signup_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignUp
Expand DownExpand Up@@ -159,7 +219,7 @@ describe('State', () => {
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignIn (simulating client refresh with null sign_in)
const _nullSignIn = new SignIn(null);
new SignIn(null);

// Assert: Signal should NOT be updated - should still have the existing SignIn
expect(signInResourceSignal().resource).toBe(existingSignIn);
Expand DownExpand Up@@ -201,16 +261,13 @@ describe('State', () => {
resetSignIn: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignInFromReset = new SignIn(null);
this.signIn = newSignInFromReset;
eventBus.emit('resource:error', { resource: newSignInFromReset, error: null });
eventBus.emit('resource:update', { resource: newSignInFromReset });
}),
};
SignIn.clerk = { client: mockClient } as any;

// Create a SignIn with id
const existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Call reset()
await existingSignIn.__internal_future.reset();
Expand All@@ -224,10 +281,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignIn with id
const _existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Act: Emit a resource update with a different SignIn that also has an id
// Act: Create a different SignIn that also has an id
const newSignIn = new SignIn({ id: 'signin_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignIn
Expand All@@ -240,19 +297,19 @@ describe('State', () => {
describe('Edge cases', () => {
it('should handle rapid successive updates correctly', () => {
// First update with valid SignUp
const _signUp1 = new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_1');

// Second update with another valid SignUp
const _signUp2 = new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Null update should be ignored
const _nullSignUp = new SignUp(null);
new SignUp(null);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Another valid update should work
const _signUp3 = new SignUp({ id: 'signup_3', status: 'complete' } as any);
new SignUp({ id: 'signup_3', status: 'complete' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_3');
});

Expand All@@ -262,10 +319,47 @@ describe('State', () => {
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Manually emit update with the same instance (simulating fromJSON on same instance)
eventBus.emit('resource:update', { resource: signUp });
eventBus.emit('resource:state-change', { resource: signUp });

// Signal should still have the same instance
expect(signUpResourceSignal().resource).toBe(signUp);
});
});

describe('Client.destroy()', () => {
it('should update signals when resources are replaced with null instances', async () => {
const mockSetActive = vi.fn().mockResolvedValue({});
SignIn.clerk = { setActive: mockSetActive, client: { sessions: [{ id: 'session_123' }] } } as any;

const existingSignIn = new SignIn({
id: 'signin_123',
status: 'complete',
created_session_id: 'session_123',
} as any);
expect(signInResourceSignal().resource).toBe(existingSignIn);

await existingSignIn.__internal_future.finalize();
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(true);

// Simulates what Client.destroy() does — creating a null resource replaces the existing one
const nullSignIn = new SignIn(null);

expect(signInResourceSignal().resource).toBe(nullSignIn);
expect(signInResourceSignal().resource?.id).toBeUndefined();
});
});

describe('fetchStatus clearing on reset', () => {
it('should clear fetchStatus to idle when resource is reset during an in-flight fetch', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Reset replaces the resource and clears fetchStatus in one event
const nullSignIn = new SignIn(null);
eventBus.emit('resource:state-change', { resource: nullSignIn, error: null, fetchStatus: 'idle' });

expect(signInFetchSignal().status).toBe('idle');
});
});
});
16 changes: 7 additions & 9 deletions packages/clerk-js/src/core/events.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,24 +9,22 @@ export const events = {
UserSignOut: 'user:signOut',
EnvironmentUpdate: 'environment:update',
SessionTokenResolved: 'session:tokenResolved',
ResourceUpdate: 'resource:update',
ResourceError: 'resource:error',
ResourceFetch: 'resource:fetch',
ResourceStateChange: 'resource:state-change',
} as const;

type TokenUpdatePayload = { token: TokenResource | null };
export type ResourceUpdatePayload = { resource: BaseResource };
export type ResourceErrorPayload = { resource: BaseResource; error: ClerkError | null };
export type ResourceFetchPayload = { resource: BaseResource; status: 'idle' | 'fetching' };
export type ResourceStateChangePayload = {
resource: BaseResource;
error?: ClerkError | null;
fetchStatus?: 'idle' | 'fetching';
};

type InternalEvents = {
[events.TokenUpdate]: TokenUpdatePayload;
[events.UserSignOut]: null;
[events.EnvironmentUpdate]: null;
[events.SessionTokenResolved]: null;
[events.ResourceUpdate]: ResourceUpdatePayload;
[events.ResourceError]: ResourceErrorPayload;
[events.ResourceFetch]: ResourceFetchPayload;
[events.ResourceStateChange]: ResourceStateChangePayload;
};

export const eventBus = createEventBus<InternalEvents>();
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,13 +106,13 @@ export class Client extends BaseResource implements ClientResource {
resetSignIn(): void {
this.signIn = new SignIn(null);
// Cast needed because this.signIn is typed as SignInResource (interface), not SignIn (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signIn as SignIn, error: null });
eventBus.emit('resource:state-change', { resource: this.signIn as SignIn, error: null, fetchStatus: 'idle' });
}

resetSignUp(): void {
this.signUp = new SignUp(null);
// Cast needed because this.signUp is typed as SignUpResource (interface), not SignUp (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signUp as SignUp, error: null });
eventBus.emit('resource:state-change', { resource: this.signUp as SignUp, error: null, fetchStatus: 'idle' });
}

clearCache(): void {
Expand Down
16 changes: 12 additions & 4 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -596,7 +596,7 @@ export class SignIn extends BaseResource implements SignInResource {
this.clientTrustState = data.client_trust_state ?? undefined;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1417,14 +1417,19 @@ class SignInFuture implements SignInFutureResource {
return this.create({ ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-in by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-in
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignInFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};

if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-in without a created session.');
}

return runAsyncResourceTask(this.#resource, async () => {
try {
// Reload the client if the created session is not in the client's sessions. This can happen during modal SSO
// flows where the in-memory client does not have the created session.
if (SignIn.clerk.client && !SignIn.clerk.client.sessions.some(s => s.id === this.#resource.createdSessionId)) {
Expand All@@ -1433,12 +1438,15 @@ class SignInFuture implements SignInFutureResource {

this.#canBeDiscarded = true;
await SignIn.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-in attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
18 changes: 13 additions & 5 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -510,7 +510,7 @@ export class SignUp extends BaseResource implements SignUpResource {
this.locale = data.locale;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1138,21 +1138,29 @@ class SignUpFuture implements SignUpFutureResource {
return this.create({ ...params, ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-up by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-up
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignUpFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};
return runAsyncResourceTask(this.#resource, async () => {

try {
if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-up without a created session.');
}

this.#canBeDiscarded = true;
await SignUp.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-up attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Waitlist.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,13 +27,13 @@ export class Waitlist extends BaseResource implements WaitlistResource {
this.updatedAt = unixEpochToDate(data.updated_at);
this.createdAt = unixEpochToDate(data.created_at);

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

async join(params: JoinWaitlistParams): Promise<{ error: ClerkError | null }> {
return runAsyncResourceTask(this, async () => {
await Waitlist.join(params);
await this._basePost({ path: this.pathRoot, body: params });
});
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fiery-games-pick.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix inconsistent `fetchStatus` during sign-in and sign-up flows where the resource would briefly show as `complete` while `fetchStatus` was still `fetching`
134 changes: 114 additions & 20 deletions packages/clerk-js/src/core/__tests__/state.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,74 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { eventBus } from '../events';
import { SignIn } from '../resources/SignIn';
import { SignUp } from '../resources/SignUp';
import { signInResourceSignal, signUpResourceSignal } from '../signals';
import { signInFetchSignal, signInResourceSignal, signUpResourceSignal } from '../signals';
import { State } from '../state';

describe('Signal batching', () => {
let state: State;

beforeEach(() => {
signInResourceSignal({ resource: null });
signInFetchSignal({ status: 'idle' });
state = new State();
});

it('should produce at most 3 renders with clean fetchStatus transitions during an API call', async () => {
const signIn = new SignIn(null);
const snapshots: Array<{ fetchStatus: string; hasSignIn: boolean }> = [];

state.__internal_effect(() => {
const s = state.signInSignal();
snapshots.push({ fetchStatus: s.fetchStatus, hasSignIn: s.signIn !== null });
});

await signIn.__internal_future.password({ password: 'test123', identifier: 'test@example.com' }).catch(() => {
// Expected to fail since there's no real API
});

expect(snapshots.length).toBeLessThanOrEqual(3);

// fetchStatus follows a clean idle → fetching → idle progression
const transitions = snapshots.map(s => s.fetchStatus).filter((s, i, arr) => i === 0 || s !== arr[i - 1]);
expect(transitions).toEqual(['idle', 'fetching', 'idle']);
});

it('should skip resource-only updates while fetching and apply them on idle', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

// Simulate fetchStatus: fetching (as runAsyncResourceTask would)
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Simulate fromJSON updating the resource mid-flight (resource-only event)
eventBus.emit('resource:state-change', { resource: signIn });

// Resource signal should NOT have been updated — skipped while fetching
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Simulate task completion — resource is carried again with fetchStatus: idle
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'idle' });

// Now both resource and fetchStatus are consistent
expect(signInFetchSignal().status).toBe('idle');
expect(signInResourceSignal().resource).toBe(signIn);
});

it('should reflect new resource data immediately when no operation is in flight', () => {
let latestSignInId: string | undefined;

state.__internal_effect(() => {
latestSignInId = state.signInSignal().signIn?.id;
});

expect(latestSignInId).toBeUndefined();

new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

expect(latestSignInId).toBe('signin_123');
});
});

describe('State', () => {
let _state: State;

Expand DownExpand Up@@ -52,7 +117,7 @@ describe('State', () => {
expect(existingSignUp.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignUp (simulating client refresh with null sign_up)
const _nullSignUp = new SignUp(null);
new SignUp(null);

// Assert: Signal should NOT be updated - should still have the existing SignUp
expect(signUpResourceSignal().resource).toBe(existingSignUp);
Expand DownExpand Up@@ -96,11 +161,6 @@ describe('State', () => {
resetSignUp: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignUpFromReset = new SignUp(null);
this.signUp = newSignUpFromReset;
// reset() emits resource:error to clear errors, but the signal update
// happens via resource:update when the new SignUp is created
eventBus.emit('resource:error', { resource: newSignUpFromReset, error: null });
// Emit resource:update to update the signal (simulating what happens in real flow)
eventBus.emit('resource:update', { resource: newSignUpFromReset });
}),
};
SignUp.clerk = { client: mockClient } as any;
Expand All@@ -127,10 +187,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignUp with id
const _existingSignUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Act: Emit a resource update with a different SignUp that also has an id
// Act: Create a different SignUp that also has an id
const newSignUp = new SignUp({ id: 'signup_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignUp
Expand DownExpand Up@@ -159,7 +219,7 @@ describe('State', () => {
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignIn (simulating client refresh with null sign_in)
const _nullSignIn = new SignIn(null);
new SignIn(null);

// Assert: Signal should NOT be updated - should still have the existing SignIn
expect(signInResourceSignal().resource).toBe(existingSignIn);
Expand DownExpand Up@@ -201,16 +261,13 @@ describe('State', () => {
resetSignIn: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignInFromReset = new SignIn(null);
this.signIn = newSignInFromReset;
eventBus.emit('resource:error', { resource: newSignInFromReset, error: null });
eventBus.emit('resource:update', { resource: newSignInFromReset });
}),
};
SignIn.clerk = { client: mockClient } as any;

// Create a SignIn with id
const existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Call reset()
await existingSignIn.__internal_future.reset();
Expand All@@ -224,10 +281,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignIn with id
const _existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Act: Emit a resource update with a different SignIn that also has an id
// Act: Create a different SignIn that also has an id
const newSignIn = new SignIn({ id: 'signin_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignIn
Expand All@@ -240,19 +297,19 @@ describe('State', () => {
describe('Edge cases', () => {
it('should handle rapid successive updates correctly', () => {
// First update with valid SignUp
const _signUp1 = new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_1');

// Second update with another valid SignUp
const _signUp2 = new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Null update should be ignored
const _nullSignUp = new SignUp(null);
new SignUp(null);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Another valid update should work
const _signUp3 = new SignUp({ id: 'signup_3', status: 'complete' } as any);
new SignUp({ id: 'signup_3', status: 'complete' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_3');
});

Expand All@@ -262,10 +319,47 @@ describe('State', () => {
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Manually emit update with the same instance (simulating fromJSON on same instance)
eventBus.emit('resource:update', { resource: signUp });
eventBus.emit('resource:state-change', { resource: signUp });

// Signal should still have the same instance
expect(signUpResourceSignal().resource).toBe(signUp);
});
});

describe('Client.destroy()', () => {
it('should update signals when resources are replaced with null instances', async () => {
const mockSetActive = vi.fn().mockResolvedValue({});
SignIn.clerk = { setActive: mockSetActive, client: { sessions: [{ id: 'session_123' }] } } as any;

const existingSignIn = new SignIn({
id: 'signin_123',
status: 'complete',
created_session_id: 'session_123',
} as any);
expect(signInResourceSignal().resource).toBe(existingSignIn);

await existingSignIn.__internal_future.finalize();
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(true);

// Simulates what Client.destroy() does — creating a null resource replaces the existing one
const nullSignIn = new SignIn(null);

expect(signInResourceSignal().resource).toBe(nullSignIn);
expect(signInResourceSignal().resource?.id).toBeUndefined();
});
});

describe('fetchStatus clearing on reset', () => {
it('should clear fetchStatus to idle when resource is reset during an in-flight fetch', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Reset replaces the resource and clears fetchStatus in one event
const nullSignIn = new SignIn(null);
eventBus.emit('resource:state-change', { resource: nullSignIn, error: null, fetchStatus: 'idle' });

expect(signInFetchSignal().status).toBe('idle');
});
});
});
16 changes: 7 additions & 9 deletions packages/clerk-js/src/core/events.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,24 +9,22 @@ export const events = {
UserSignOut: 'user:signOut',
EnvironmentUpdate: 'environment:update',
SessionTokenResolved: 'session:tokenResolved',
ResourceUpdate: 'resource:update',
ResourceError: 'resource:error',
ResourceFetch: 'resource:fetch',
ResourceStateChange: 'resource:state-change',
} as const;

type TokenUpdatePayload = { token: TokenResource | null };
export type ResourceUpdatePayload = { resource: BaseResource };
export type ResourceErrorPayload = { resource: BaseResource; error: ClerkError | null };
export type ResourceFetchPayload = { resource: BaseResource; status: 'idle' | 'fetching' };
export type ResourceStateChangePayload = {
resource: BaseResource;
error?: ClerkError | null;
fetchStatus?: 'idle' | 'fetching';
};

type InternalEvents = {
[events.TokenUpdate]: TokenUpdatePayload;
[events.UserSignOut]: null;
[events.EnvironmentUpdate]: null;
[events.SessionTokenResolved]: null;
[events.ResourceUpdate]: ResourceUpdatePayload;
[events.ResourceError]: ResourceErrorPayload;
[events.ResourceFetch]: ResourceFetchPayload;
[events.ResourceStateChange]: ResourceStateChangePayload;
};

export const eventBus = createEventBus<InternalEvents>();
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,13 +106,13 @@ export class Client extends BaseResource implements ClientResource {
resetSignIn(): void {
this.signIn = new SignIn(null);
// Cast needed because this.signIn is typed as SignInResource (interface), not SignIn (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signIn as SignIn, error: null });
eventBus.emit('resource:state-change', { resource: this.signIn as SignIn, error: null, fetchStatus: 'idle' });
}

resetSignUp(): void {
this.signUp = new SignUp(null);
// Cast needed because this.signUp is typed as SignUpResource (interface), not SignUp (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signUp as SignUp, error: null });
eventBus.emit('resource:state-change', { resource: this.signUp as SignUp, error: null, fetchStatus: 'idle' });
}

clearCache(): void {
Expand Down
16 changes: 12 additions & 4 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -596,7 +596,7 @@ export class SignIn extends BaseResource implements SignInResource {
this.clientTrustState = data.client_trust_state ?? undefined;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1417,14 +1417,19 @@ class SignInFuture implements SignInFutureResource {
return this.create({ ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-in by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-in
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignInFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};

if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-in without a created session.');
}

return runAsyncResourceTask(this.#resource, async () => {
try {
// Reload the client if the created session is not in the client's sessions. This can happen during modal SSO
// flows where the in-memory client does not have the created session.
if (SignIn.clerk.client && !SignIn.clerk.client.sessions.some(s => s.id === this.#resource.createdSessionId)) {
Expand All@@ -1433,12 +1438,15 @@ class SignInFuture implements SignInFutureResource {

this.#canBeDiscarded = true;
await SignIn.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-in attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
18 changes: 13 additions & 5 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -510,7 +510,7 @@ export class SignUp extends BaseResource implements SignUpResource {
this.locale = data.locale;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1138,21 +1138,29 @@ class SignUpFuture implements SignUpFutureResource {
return this.create({ ...params, ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-up by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-up
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignUpFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};
return runAsyncResourceTask(this.#resource, async () => {

try {
if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-up without a created session.');
}

this.#canBeDiscarded = true;
await SignUp.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-up attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Waitlist.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,13 +27,13 @@ export class Waitlist extends BaseResource implements WaitlistResource {
this.updatedAt = unixEpochToDate(data.updated_at);
this.createdAt = unixEpochToDate(data.created_at);

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

async join(params: JoinWaitlistParams): Promise<{ error: ClerkError | null }> {
return runAsyncResourceTask(this, async () => {
await Waitlist.join(params);
await this._basePost({ path: this.pathRoot, body: params });
});
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fiery-games-pick.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix inconsistent `fetchStatus` during sign-in and sign-up flows where the resource would briefly show as `complete` while `fetchStatus` was still `fetching`
134 changes: 114 additions & 20 deletions packages/clerk-js/src/core/__tests__/state.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,74 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { eventBus } from '../events';
import { SignIn } from '../resources/SignIn';
import { SignUp } from '../resources/SignUp';
import { signInResourceSignal, signUpResourceSignal } from '../signals';
import { signInFetchSignal, signInResourceSignal, signUpResourceSignal } from '../signals';
import { State } from '../state';

describe('Signal batching', () => {
let state: State;

beforeEach(() => {
signInResourceSignal({ resource: null });
signInFetchSignal({ status: 'idle' });
state = new State();
});

it('should produce at most 3 renders with clean fetchStatus transitions during an API call', async () => {
const signIn = new SignIn(null);
const snapshots: Array<{ fetchStatus: string; hasSignIn: boolean }> = [];

state.__internal_effect(() => {
const s = state.signInSignal();
snapshots.push({ fetchStatus: s.fetchStatus, hasSignIn: s.signIn !== null });
});

await signIn.__internal_future.password({ password: 'test123', identifier: 'test@example.com' }).catch(() => {
// Expected to fail since there's no real API
});

expect(snapshots.length).toBeLessThanOrEqual(3);

// fetchStatus follows a clean idle → fetching → idle progression
const transitions = snapshots.map(s => s.fetchStatus).filter((s, i, arr) => i === 0 || s !== arr[i - 1]);
expect(transitions).toEqual(['idle', 'fetching', 'idle']);
});

it('should skip resource-only updates while fetching and apply them on idle', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

// Simulate fetchStatus: fetching (as runAsyncResourceTask would)
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Simulate fromJSON updating the resource mid-flight (resource-only event)
eventBus.emit('resource:state-change', { resource: signIn });

// Resource signal should NOT have been updated — skipped while fetching
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Simulate task completion — resource is carried again with fetchStatus: idle
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'idle' });

// Now both resource and fetchStatus are consistent
expect(signInFetchSignal().status).toBe('idle');
expect(signInResourceSignal().resource).toBe(signIn);
});

it('should reflect new resource data immediately when no operation is in flight', () => {
let latestSignInId: string | undefined;

state.__internal_effect(() => {
latestSignInId = state.signInSignal().signIn?.id;
});

expect(latestSignInId).toBeUndefined();

new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

expect(latestSignInId).toBe('signin_123');
});
});

describe('State', () => {
let _state: State;

Expand DownExpand Up@@ -52,7 +117,7 @@ describe('State', () => {
expect(existingSignUp.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignUp (simulating client refresh with null sign_up)
const _nullSignUp = new SignUp(null);
new SignUp(null);

// Assert: Signal should NOT be updated - should still have the existing SignUp
expect(signUpResourceSignal().resource).toBe(existingSignUp);
Expand DownExpand Up@@ -96,11 +161,6 @@ describe('State', () => {
resetSignUp: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignUpFromReset = new SignUp(null);
this.signUp = newSignUpFromReset;
// reset() emits resource:error to clear errors, but the signal update
// happens via resource:update when the new SignUp is created
eventBus.emit('resource:error', { resource: newSignUpFromReset, error: null });
// Emit resource:update to update the signal (simulating what happens in real flow)
eventBus.emit('resource:update', { resource: newSignUpFromReset });
}),
};
SignUp.clerk = { client: mockClient } as any;
Expand All@@ -127,10 +187,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignUp with id
const _existingSignUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Act: Emit a resource update with a different SignUp that also has an id
// Act: Create a different SignUp that also has an id
const newSignUp = new SignUp({ id: 'signup_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignUp
Expand DownExpand Up@@ -159,7 +219,7 @@ describe('State', () => {
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignIn (simulating client refresh with null sign_in)
const _nullSignIn = new SignIn(null);
new SignIn(null);

// Assert: Signal should NOT be updated - should still have the existing SignIn
expect(signInResourceSignal().resource).toBe(existingSignIn);
Expand DownExpand Up@@ -201,16 +261,13 @@ describe('State', () => {
resetSignIn: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignInFromReset = new SignIn(null);
this.signIn = newSignInFromReset;
eventBus.emit('resource:error', { resource: newSignInFromReset, error: null });
eventBus.emit('resource:update', { resource: newSignInFromReset });
}),
};
SignIn.clerk = { client: mockClient } as any;

// Create a SignIn with id
const existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Call reset()
await existingSignIn.__internal_future.reset();
Expand All@@ -224,10 +281,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignIn with id
const _existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Act: Emit a resource update with a different SignIn that also has an id
// Act: Create a different SignIn that also has an id
const newSignIn = new SignIn({ id: 'signin_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignIn
Expand All@@ -240,19 +297,19 @@ describe('State', () => {
describe('Edge cases', () => {
it('should handle rapid successive updates correctly', () => {
// First update with valid SignUp
const _signUp1 = new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_1');

// Second update with another valid SignUp
const _signUp2 = new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Null update should be ignored
const _nullSignUp = new SignUp(null);
new SignUp(null);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Another valid update should work
const _signUp3 = new SignUp({ id: 'signup_3', status: 'complete' } as any);
new SignUp({ id: 'signup_3', status: 'complete' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_3');
});

Expand All@@ -262,10 +319,47 @@ describe('State', () => {
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Manually emit update with the same instance (simulating fromJSON on same instance)
eventBus.emit('resource:update', { resource: signUp });
eventBus.emit('resource:state-change', { resource: signUp });

// Signal should still have the same instance
expect(signUpResourceSignal().resource).toBe(signUp);
});
});

describe('Client.destroy()', () => {
it('should update signals when resources are replaced with null instances', async () => {
const mockSetActive = vi.fn().mockResolvedValue({});
SignIn.clerk = { setActive: mockSetActive, client: { sessions: [{ id: 'session_123' }] } } as any;

const existingSignIn = new SignIn({
id: 'signin_123',
status: 'complete',
created_session_id: 'session_123',
} as any);
expect(signInResourceSignal().resource).toBe(existingSignIn);

await existingSignIn.__internal_future.finalize();
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(true);

// Simulates what Client.destroy() does — creating a null resource replaces the existing one
const nullSignIn = new SignIn(null);

expect(signInResourceSignal().resource).toBe(nullSignIn);
expect(signInResourceSignal().resource?.id).toBeUndefined();
});
});

describe('fetchStatus clearing on reset', () => {
it('should clear fetchStatus to idle when resource is reset during an in-flight fetch', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Reset replaces the resource and clears fetchStatus in one event
const nullSignIn = new SignIn(null);
eventBus.emit('resource:state-change', { resource: nullSignIn, error: null, fetchStatus: 'idle' });

expect(signInFetchSignal().status).toBe('idle');
});
});
});
16 changes: 7 additions & 9 deletions packages/clerk-js/src/core/events.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,24 +9,22 @@ export const events = {
UserSignOut: 'user:signOut',
EnvironmentUpdate: 'environment:update',
SessionTokenResolved: 'session:tokenResolved',
ResourceUpdate: 'resource:update',
ResourceError: 'resource:error',
ResourceFetch: 'resource:fetch',
ResourceStateChange: 'resource:state-change',
} as const;

type TokenUpdatePayload = { token: TokenResource | null };
export type ResourceUpdatePayload = { resource: BaseResource };
export type ResourceErrorPayload = { resource: BaseResource; error: ClerkError | null };
export type ResourceFetchPayload = { resource: BaseResource; status: 'idle' | 'fetching' };
export type ResourceStateChangePayload = {
resource: BaseResource;
error?: ClerkError | null;
fetchStatus?: 'idle' | 'fetching';
};

type InternalEvents = {
[events.TokenUpdate]: TokenUpdatePayload;
[events.UserSignOut]: null;
[events.EnvironmentUpdate]: null;
[events.SessionTokenResolved]: null;
[events.ResourceUpdate]: ResourceUpdatePayload;
[events.ResourceError]: ResourceErrorPayload;
[events.ResourceFetch]: ResourceFetchPayload;
[events.ResourceStateChange]: ResourceStateChangePayload;
};

export const eventBus = createEventBus<InternalEvents>();
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,13 +106,13 @@ export class Client extends BaseResource implements ClientResource {
resetSignIn(): void {
this.signIn = new SignIn(null);
// Cast needed because this.signIn is typed as SignInResource (interface), not SignIn (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signIn as SignIn, error: null });
eventBus.emit('resource:state-change', { resource: this.signIn as SignIn, error: null, fetchStatus: 'idle' });
}

resetSignUp(): void {
this.signUp = new SignUp(null);
// Cast needed because this.signUp is typed as SignUpResource (interface), not SignUp (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signUp as SignUp, error: null });
eventBus.emit('resource:state-change', { resource: this.signUp as SignUp, error: null, fetchStatus: 'idle' });
}

clearCache(): void {
Expand Down
16 changes: 12 additions & 4 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -596,7 +596,7 @@ export class SignIn extends BaseResource implements SignInResource {
this.clientTrustState = data.client_trust_state ?? undefined;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1417,14 +1417,19 @@ class SignInFuture implements SignInFutureResource {
return this.create({ ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-in by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-in
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignInFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};

if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-in without a created session.');
}

return runAsyncResourceTask(this.#resource, async () => {
try {
// Reload the client if the created session is not in the client's sessions. This can happen during modal SSO
// flows where the in-memory client does not have the created session.
if (SignIn.clerk.client && !SignIn.clerk.client.sessions.some(s => s.id === this.#resource.createdSessionId)) {
Expand All@@ -1433,12 +1438,15 @@ class SignInFuture implements SignInFutureResource {

this.#canBeDiscarded = true;
await SignIn.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-in attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
18 changes: 13 additions & 5 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -510,7 +510,7 @@ export class SignUp extends BaseResource implements SignUpResource {
this.locale = data.locale;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1138,21 +1138,29 @@ class SignUpFuture implements SignUpFutureResource {
return this.create({ ...params, ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-up by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-up
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignUpFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};
return runAsyncResourceTask(this.#resource, async () => {

try {
if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-up without a created session.');
}

this.#canBeDiscarded = true;
await SignUp.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-up attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Waitlist.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,13 +27,13 @@ export class Waitlist extends BaseResource implements WaitlistResource {
this.updatedAt = unixEpochToDate(data.updated_at);
this.createdAt = unixEpochToDate(data.created_at);

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

async join(params: JoinWaitlistParams): Promise<{ error: ClerkError | null }> {
return runAsyncResourceTask(this, async () => {
await Waitlist.join(params);
await this._basePost({ path: this.pathRoot, body: params });
});
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fiery-games-pick.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix inconsistent `fetchStatus` during sign-in and sign-up flows where the resource would briefly show as `complete` while `fetchStatus` was still `fetching`
134 changes: 114 additions & 20 deletions packages/clerk-js/src/core/__tests__/state.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,74 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { eventBus } from '../events';
import { SignIn } from '../resources/SignIn';
import { SignUp } from '../resources/SignUp';
import { signInResourceSignal, signUpResourceSignal } from '../signals';
import { signInFetchSignal, signInResourceSignal, signUpResourceSignal } from '../signals';
import { State } from '../state';

describe('Signal batching', () => {
let state: State;

beforeEach(() => {
signInResourceSignal({ resource: null });
signInFetchSignal({ status: 'idle' });
state = new State();
});

it('should produce at most 3 renders with clean fetchStatus transitions during an API call', async () => {
const signIn = new SignIn(null);
const snapshots: Array<{ fetchStatus: string; hasSignIn: boolean }> = [];

state.__internal_effect(() => {
const s = state.signInSignal();
snapshots.push({ fetchStatus: s.fetchStatus, hasSignIn: s.signIn !== null });
});

await signIn.__internal_future.password({ password: 'test123', identifier: 'test@example.com' }).catch(() => {
// Expected to fail since there's no real API
});

expect(snapshots.length).toBeLessThanOrEqual(3);

// fetchStatus follows a clean idle → fetching → idle progression
const transitions = snapshots.map(s => s.fetchStatus).filter((s, i, arr) => i === 0 || s !== arr[i - 1]);
expect(transitions).toEqual(['idle', 'fetching', 'idle']);
});

it('should skip resource-only updates while fetching and apply them on idle', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

// Simulate fetchStatus: fetching (as runAsyncResourceTask would)
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Simulate fromJSON updating the resource mid-flight (resource-only event)
eventBus.emit('resource:state-change', { resource: signIn });

// Resource signal should NOT have been updated — skipped while fetching
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Simulate task completion — resource is carried again with fetchStatus: idle
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'idle' });

// Now both resource and fetchStatus are consistent
expect(signInFetchSignal().status).toBe('idle');
expect(signInResourceSignal().resource).toBe(signIn);
});

it('should reflect new resource data immediately when no operation is in flight', () => {
let latestSignInId: string | undefined;

state.__internal_effect(() => {
latestSignInId = state.signInSignal().signIn?.id;
});

expect(latestSignInId).toBeUndefined();

new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

expect(latestSignInId).toBe('signin_123');
});
});

describe('State', () => {
let _state: State;

Expand DownExpand Up@@ -52,7 +117,7 @@ describe('State', () => {
expect(existingSignUp.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignUp (simulating client refresh with null sign_up)
const _nullSignUp = new SignUp(null);
new SignUp(null);

// Assert: Signal should NOT be updated - should still have the existing SignUp
expect(signUpResourceSignal().resource).toBe(existingSignUp);
Expand DownExpand Up@@ -96,11 +161,6 @@ describe('State', () => {
resetSignUp: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignUpFromReset = new SignUp(null);
this.signUp = newSignUpFromReset;
// reset() emits resource:error to clear errors, but the signal update
// happens via resource:update when the new SignUp is created
eventBus.emit('resource:error', { resource: newSignUpFromReset, error: null });
// Emit resource:update to update the signal (simulating what happens in real flow)
eventBus.emit('resource:update', { resource: newSignUpFromReset });
}),
};
SignUp.clerk = { client: mockClient } as any;
Expand All@@ -127,10 +187,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignUp with id
const _existingSignUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Act: Emit a resource update with a different SignUp that also has an id
// Act: Create a different SignUp that also has an id
const newSignUp = new SignUp({ id: 'signup_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignUp
Expand DownExpand Up@@ -159,7 +219,7 @@ describe('State', () => {
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignIn (simulating client refresh with null sign_in)
const _nullSignIn = new SignIn(null);
new SignIn(null);

// Assert: Signal should NOT be updated - should still have the existing SignIn
expect(signInResourceSignal().resource).toBe(existingSignIn);
Expand DownExpand Up@@ -201,16 +261,13 @@ describe('State', () => {
resetSignIn: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignInFromReset = new SignIn(null);
this.signIn = newSignInFromReset;
eventBus.emit('resource:error', { resource: newSignInFromReset, error: null });
eventBus.emit('resource:update', { resource: newSignInFromReset });
}),
};
SignIn.clerk = { client: mockClient } as any;

// Create a SignIn with id
const existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Call reset()
await existingSignIn.__internal_future.reset();
Expand All@@ -224,10 +281,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignIn with id
const _existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Act: Emit a resource update with a different SignIn that also has an id
// Act: Create a different SignIn that also has an id
const newSignIn = new SignIn({ id: 'signin_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignIn
Expand All@@ -240,19 +297,19 @@ describe('State', () => {
describe('Edge cases', () => {
it('should handle rapid successive updates correctly', () => {
// First update with valid SignUp
const _signUp1 = new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_1');

// Second update with another valid SignUp
const _signUp2 = new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Null update should be ignored
const _nullSignUp = new SignUp(null);
new SignUp(null);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Another valid update should work
const _signUp3 = new SignUp({ id: 'signup_3', status: 'complete' } as any);
new SignUp({ id: 'signup_3', status: 'complete' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_3');
});

Expand All@@ -262,10 +319,47 @@ describe('State', () => {
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Manually emit update with the same instance (simulating fromJSON on same instance)
eventBus.emit('resource:update', { resource: signUp });
eventBus.emit('resource:state-change', { resource: signUp });

// Signal should still have the same instance
expect(signUpResourceSignal().resource).toBe(signUp);
});
});

describe('Client.destroy()', () => {
it('should update signals when resources are replaced with null instances', async () => {
const mockSetActive = vi.fn().mockResolvedValue({});
SignIn.clerk = { setActive: mockSetActive, client: { sessions: [{ id: 'session_123' }] } } as any;

const existingSignIn = new SignIn({
id: 'signin_123',
status: 'complete',
created_session_id: 'session_123',
} as any);
expect(signInResourceSignal().resource).toBe(existingSignIn);

await existingSignIn.__internal_future.finalize();
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(true);

// Simulates what Client.destroy() does — creating a null resource replaces the existing one
const nullSignIn = new SignIn(null);

expect(signInResourceSignal().resource).toBe(nullSignIn);
expect(signInResourceSignal().resource?.id).toBeUndefined();
});
});

describe('fetchStatus clearing on reset', () => {
it('should clear fetchStatus to idle when resource is reset during an in-flight fetch', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Reset replaces the resource and clears fetchStatus in one event
const nullSignIn = new SignIn(null);
eventBus.emit('resource:state-change', { resource: nullSignIn, error: null, fetchStatus: 'idle' });

expect(signInFetchSignal().status).toBe('idle');
});
});
});
16 changes: 7 additions & 9 deletions packages/clerk-js/src/core/events.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,24 +9,22 @@ export const events = {
UserSignOut: 'user:signOut',
EnvironmentUpdate: 'environment:update',
SessionTokenResolved: 'session:tokenResolved',
ResourceUpdate: 'resource:update',
ResourceError: 'resource:error',
ResourceFetch: 'resource:fetch',
ResourceStateChange: 'resource:state-change',
} as const;

type TokenUpdatePayload = { token: TokenResource | null };
export type ResourceUpdatePayload = { resource: BaseResource };
export type ResourceErrorPayload = { resource: BaseResource; error: ClerkError | null };
export type ResourceFetchPayload = { resource: BaseResource; status: 'idle' | 'fetching' };
export type ResourceStateChangePayload = {
resource: BaseResource;
error?: ClerkError | null;
fetchStatus?: 'idle' | 'fetching';
};

type InternalEvents = {
[events.TokenUpdate]: TokenUpdatePayload;
[events.UserSignOut]: null;
[events.EnvironmentUpdate]: null;
[events.SessionTokenResolved]: null;
[events.ResourceUpdate]: ResourceUpdatePayload;
[events.ResourceError]: ResourceErrorPayload;
[events.ResourceFetch]: ResourceFetchPayload;
[events.ResourceStateChange]: ResourceStateChangePayload;
};

export const eventBus = createEventBus<InternalEvents>();
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,13 +106,13 @@ export class Client extends BaseResource implements ClientResource {
resetSignIn(): void {
this.signIn = new SignIn(null);
// Cast needed because this.signIn is typed as SignInResource (interface), not SignIn (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signIn as SignIn, error: null });
eventBus.emit('resource:state-change', { resource: this.signIn as SignIn, error: null, fetchStatus: 'idle' });
}

resetSignUp(): void {
this.signUp = new SignUp(null);
// Cast needed because this.signUp is typed as SignUpResource (interface), not SignUp (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signUp as SignUp, error: null });
eventBus.emit('resource:state-change', { resource: this.signUp as SignUp, error: null, fetchStatus: 'idle' });
}

clearCache(): void {
Expand Down
16 changes: 12 additions & 4 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -596,7 +596,7 @@ export class SignIn extends BaseResource implements SignInResource {
this.clientTrustState = data.client_trust_state ?? undefined;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1417,14 +1417,19 @@ class SignInFuture implements SignInFutureResource {
return this.create({ ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-in by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-in
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignInFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};

if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-in without a created session.');
}

return runAsyncResourceTask(this.#resource, async () => {
try {
// Reload the client if the created session is not in the client's sessions. This can happen during modal SSO
// flows where the in-memory client does not have the created session.
if (SignIn.clerk.client && !SignIn.clerk.client.sessions.some(s => s.id === this.#resource.createdSessionId)) {
Expand All@@ -1433,12 +1438,15 @@ class SignInFuture implements SignInFutureResource {

this.#canBeDiscarded = true;
await SignIn.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-in attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
18 changes: 13 additions & 5 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -510,7 +510,7 @@ export class SignUp extends BaseResource implements SignUpResource {
this.locale = data.locale;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1138,21 +1138,29 @@ class SignUpFuture implements SignUpFutureResource {
return this.create({ ...params, ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-up by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-up
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignUpFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};
return runAsyncResourceTask(this.#resource, async () => {

try {
if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-up without a created session.');
}

this.#canBeDiscarded = true;
await SignUp.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-up attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Waitlist.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,13 +27,13 @@ export class Waitlist extends BaseResource implements WaitlistResource {
this.updatedAt = unixEpochToDate(data.updated_at);
this.createdAt = unixEpochToDate(data.created_at);

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

async join(params: JoinWaitlistParams): Promise<{ error: ClerkError | null }> {
return runAsyncResourceTask(this, async () => {
await Waitlist.join(params);
await this._basePost({ path: this.pathRoot, body: params });
});
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fiery-games-pick.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix inconsistent `fetchStatus` during sign-in and sign-up flows where the resource would briefly show as `complete` while `fetchStatus` was still `fetching`
134 changes: 114 additions & 20 deletions packages/clerk-js/src/core/__tests__/state.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,74 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { eventBus } from '../events';
import { SignIn } from '../resources/SignIn';
import { SignUp } from '../resources/SignUp';
import { signInResourceSignal, signUpResourceSignal } from '../signals';
import { signInFetchSignal, signInResourceSignal, signUpResourceSignal } from '../signals';
import { State } from '../state';

describe('Signal batching', () => {
let state: State;

beforeEach(() => {
signInResourceSignal({ resource: null });
signInFetchSignal({ status: 'idle' });
state = new State();
});

it('should produce at most 3 renders with clean fetchStatus transitions during an API call', async () => {
const signIn = new SignIn(null);
const snapshots: Array<{ fetchStatus: string; hasSignIn: boolean }> = [];

state.__internal_effect(() => {
const s = state.signInSignal();
snapshots.push({ fetchStatus: s.fetchStatus, hasSignIn: s.signIn !== null });
});

await signIn.__internal_future.password({ password: 'test123', identifier: 'test@example.com' }).catch(() => {
// Expected to fail since there's no real API
});

expect(snapshots.length).toBeLessThanOrEqual(3);

// fetchStatus follows a clean idle → fetching → idle progression
const transitions = snapshots.map(s => s.fetchStatus).filter((s, i, arr) => i === 0 || s !== arr[i - 1]);
expect(transitions).toEqual(['idle', 'fetching', 'idle']);
});

it('should skip resource-only updates while fetching and apply them on idle', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

// Simulate fetchStatus: fetching (as runAsyncResourceTask would)
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Simulate fromJSON updating the resource mid-flight (resource-only event)
eventBus.emit('resource:state-change', { resource: signIn });

// Resource signal should NOT have been updated — skipped while fetching
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Simulate task completion — resource is carried again with fetchStatus: idle
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'idle' });

// Now both resource and fetchStatus are consistent
expect(signInFetchSignal().status).toBe('idle');
expect(signInResourceSignal().resource).toBe(signIn);
});

it('should reflect new resource data immediately when no operation is in flight', () => {
let latestSignInId: string | undefined;

state.__internal_effect(() => {
latestSignInId = state.signInSignal().signIn?.id;
});

expect(latestSignInId).toBeUndefined();

new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);

expect(latestSignInId).toBe('signin_123');
});
});

describe('State', () => {
let _state: State;

Expand DownExpand Up@@ -52,7 +117,7 @@ describe('State', () => {
expect(existingSignUp.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignUp (simulating client refresh with null sign_up)
const _nullSignUp = new SignUp(null);
new SignUp(null);

// Assert: Signal should NOT be updated - should still have the existing SignUp
expect(signUpResourceSignal().resource).toBe(existingSignUp);
Expand DownExpand Up@@ -96,11 +161,6 @@ describe('State', () => {
resetSignUp: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignUpFromReset = new SignUp(null);
this.signUp = newSignUpFromReset;
// reset() emits resource:error to clear errors, but the signal update
// happens via resource:update when the new SignUp is created
eventBus.emit('resource:error', { resource: newSignUpFromReset, error: null });
// Emit resource:update to update the signal (simulating what happens in real flow)
eventBus.emit('resource:update', { resource: newSignUpFromReset });
}),
};
SignUp.clerk = { client: mockClient } as any;
Expand All@@ -127,10 +187,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignUp with id
const _existingSignUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Act: Emit a resource update with a different SignUp that also has an id
// Act: Create a different SignUp that also has an id
const newSignUp = new SignUp({ id: 'signup_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignUp
Expand DownExpand Up@@ -159,7 +219,7 @@ describe('State', () => {
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Emit a resource update with a null SignIn (simulating client refresh with null sign_in)
const _nullSignIn = new SignIn(null);
new SignIn(null);

// Assert: Signal should NOT be updated - should still have the existing SignIn
expect(signInResourceSignal().resource).toBe(existingSignIn);
Expand DownExpand Up@@ -201,16 +261,13 @@ describe('State', () => {
resetSignIn: vi.fn().mockImplementation(function (this: typeof mockClient) {
newSignInFromReset = new SignIn(null);
this.signIn = newSignInFromReset;
eventBus.emit('resource:error', { resource: newSignInFromReset, error: null });
eventBus.emit('resource:update', { resource: newSignInFromReset });
}),
};
SignIn.clerk = { client: mockClient } as any;

// Create a SignIn with id
const existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false);

// Act: Call reset()
await existingSignIn.__internal_future.reset();
Expand All@@ -224,10 +281,10 @@ describe('State', () => {

it('should allow resource update when new resource has an id (not a null update)', () => {
// Arrange: Set up a SignIn with id
const _existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
expect(signInResourceSignal().resource?.id).toBe('signin_123');

// Act: Emit a resource update with a different SignIn that also has an id
// Act: Create a different SignIn that also has an id
const newSignIn = new SignIn({ id: 'signin_456', status: 'complete' } as any);

// Assert: Signal should be updated with the new SignIn
Expand All@@ -240,19 +297,19 @@ describe('State', () => {
describe('Edge cases', () => {
it('should handle rapid successive updates correctly', () => {
// First update with valid SignUp
const _signUp1 = new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_1');

// Second update with another valid SignUp
const _signUp2 = new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Null update should be ignored
const _nullSignUp = new SignUp(null);
new SignUp(null);
expect(signUpResourceSignal().resource?.id).toBe('signup_2');

// Another valid update should work
const _signUp3 = new SignUp({ id: 'signup_3', status: 'complete' } as any);
new SignUp({ id: 'signup_3', status: 'complete' } as any);
expect(signUpResourceSignal().resource?.id).toBe('signup_3');
});

Expand All@@ -262,10 +319,47 @@ describe('State', () => {
expect(signUpResourceSignal().resource?.id).toBe('signup_123');

// Manually emit update with the same instance (simulating fromJSON on same instance)
eventBus.emit('resource:update', { resource: signUp });
eventBus.emit('resource:state-change', { resource: signUp });

// Signal should still have the same instance
expect(signUpResourceSignal().resource).toBe(signUp);
});
});

describe('Client.destroy()', () => {
it('should update signals when resources are replaced with null instances', async () => {
const mockSetActive = vi.fn().mockResolvedValue({});
SignIn.clerk = { setActive: mockSetActive, client: { sessions: [{ id: 'session_123' }] } } as any;

const existingSignIn = new SignIn({
id: 'signin_123',
status: 'complete',
created_session_id: 'session_123',
} as any);
expect(signInResourceSignal().resource).toBe(existingSignIn);

await existingSignIn.__internal_future.finalize();
expect(existingSignIn.__internal_future.canBeDiscarded).toBe(true);

// Simulates what Client.destroy() does — creating a null resource replaces the existing one
const nullSignIn = new SignIn(null);

expect(signInResourceSignal().resource).toBe(nullSignIn);
expect(signInResourceSignal().resource?.id).toBeUndefined();
});
});

describe('fetchStatus clearing on reset', () => {
it('should clear fetchStatus to idle when resource is reset during an in-flight fetch', () => {
const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any);
eventBus.emit('resource:state-change', { resource: signIn, error: null, fetchStatus: 'fetching' });
expect(signInFetchSignal().status).toBe('fetching');

// Reset replaces the resource and clears fetchStatus in one event
const nullSignIn = new SignIn(null);
eventBus.emit('resource:state-change', { resource: nullSignIn, error: null, fetchStatus: 'idle' });

expect(signInFetchSignal().status).toBe('idle');
});
});
});
16 changes: 7 additions & 9 deletions packages/clerk-js/src/core/events.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,24 +9,22 @@ export const events = {
UserSignOut: 'user:signOut',
EnvironmentUpdate: 'environment:update',
SessionTokenResolved: 'session:tokenResolved',
ResourceUpdate: 'resource:update',
ResourceError: 'resource:error',
ResourceFetch: 'resource:fetch',
ResourceStateChange: 'resource:state-change',
} as const;

type TokenUpdatePayload = { token: TokenResource | null };
export type ResourceUpdatePayload = { resource: BaseResource };
export type ResourceErrorPayload = { resource: BaseResource; error: ClerkError | null };
export type ResourceFetchPayload = { resource: BaseResource; status: 'idle' | 'fetching' };
export type ResourceStateChangePayload = {
resource: BaseResource;
error?: ClerkError | null;
fetchStatus?: 'idle' | 'fetching';
};

type InternalEvents = {
[events.TokenUpdate]: TokenUpdatePayload;
[events.UserSignOut]: null;
[events.EnvironmentUpdate]: null;
[events.SessionTokenResolved]: null;
[events.ResourceUpdate]: ResourceUpdatePayload;
[events.ResourceError]: ResourceErrorPayload;
[events.ResourceFetch]: ResourceFetchPayload;
[events.ResourceStateChange]: ResourceStateChangePayload;
};

export const eventBus = createEventBus<InternalEvents>();
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,13 +106,13 @@ export class Client extends BaseResource implements ClientResource {
resetSignIn(): void {
this.signIn = new SignIn(null);
// Cast needed because this.signIn is typed as SignInResource (interface), not SignIn (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signIn as SignIn, error: null });
eventBus.emit('resource:state-change', { resource: this.signIn as SignIn, error: null, fetchStatus: 'idle' });
}

resetSignUp(): void {
this.signUp = new SignUp(null);
// Cast needed because this.signUp is typed as SignUpResource (interface), not SignUp (class extending BaseResource)
eventBus.emit('resource:error', { resource: this.signUp as SignUp, error: null });
eventBus.emit('resource:state-change', { resource: this.signUp as SignUp, error: null, fetchStatus: 'idle' });
}

clearCache(): void {
Expand Down
16 changes: 12 additions & 4 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -596,7 +596,7 @@ export class SignIn extends BaseResource implements SignInResource {
this.clientTrustState = data.client_trust_state ?? undefined;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1417,14 +1417,19 @@ class SignInFuture implements SignInFutureResource {
return this.create({ ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-in by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-in
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignInFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};

if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-in without a created session.');
}

return runAsyncResourceTask(this.#resource, async () => {
try {
// Reload the client if the created session is not in the client's sessions. This can happen during modal SSO
// flows where the in-memory client does not have the created session.
if (SignIn.clerk.client && !SignIn.clerk.client.sessions.some(s => s.id === this.#resource.createdSessionId)) {
Expand All@@ -1433,12 +1438,15 @@ class SignInFuture implements SignInFutureResource {

this.#canBeDiscarded = true;
await SignIn.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-in attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
18 changes: 13 additions & 5 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -510,7 +510,7 @@ export class SignUp extends BaseResource implements SignUpResource {
this.locale = data.locale;
}

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

Expand DownExpand Up@@ -1138,21 +1138,29 @@ class SignUpFuture implements SignUpFutureResource {
return this.create({ ...params, ticket: ticket ?? undefined });
}

/**
* Finalizes a completed sign-up by setting the active session.
* Like reset(), this does NOT set fetchStatus to 'fetching' — the sign-up
* is already complete, and setActive is a session-level operation.
*/
async finalize(params?: SignUpFutureFinalizeParams): Promise<{ error: ClerkError | null }> {
const { navigate } = params || {};
return runAsyncResourceTask(this.#resource, async () => {

try {
if (!this.#resource.createdSessionId) {
throw new Error('Cannot finalize sign-up without a created session.');
}

this.#canBeDiscarded = true;
await SignUp.clerk.setActive({ session: this.#resource.createdSessionId, navigate });
});
return { error: null };
} catch (err) {
return { error: err as ClerkError };
}
}

/**
* Resets the current sign-up attempt by clearing all local state back to null.
* Unlike other methods, this does NOT emit resource:fetch with 'fetching' status,
* Unlike other methods, this does NOT set fetchStatus to 'fetching',
* allowing for smooth UI transitions without loading states.
*/
reset(): Promise<{ error: ClerkError | null }> {
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Waitlist.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,13 +27,13 @@ export class Waitlist extends BaseResource implements WaitlistResource {
this.updatedAt = unixEpochToDate(data.updated_at);
this.createdAt = unixEpochToDate(data.created_at);

eventBus.emit('resource:update', { resource: this });
eventBus.emit('resource:state-change', { resource: this });
return this;
}

async join(params: JoinWaitlistParams): Promise<{ error: ClerkError | null }> {
return runAsyncResourceTask(this, async () => {
await Waitlist.join(params);
await this._basePost({ path: this.pathRoot, body: params });
});
}

Expand Down
Loading
Loading