Skip to content

Commit 7b7eb1f

Browse files
authored
feat(clerk-js,types): Signals fetchStatus (#6549)
1 parent cf57f91 commit 7b7eb1f

File tree

9 files changed

+149
-88
lines changed

9 files changed

+149
-88
lines changed

.changeset/twelve-crabs-return.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/types': minor
4+
---
5+
6+
[Experimental] Signal `fetchStatus` support.

packages/clerk-js/src/core/events.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ export const events = {
1010
SessionTokenResolved: 'session:tokenResolved',
1111
ResourceUpdate: 'resource:update',
1212
ResourceError: 'resource:error',
13+
ResourceFetch: 'resource:fetch',
1314
} as const;
1415

1516
type TokenUpdatePayload = { token: TokenResource | null };
1617
export type ResourceUpdatePayload = { resource: BaseResource };
1718
export type ResourceErrorPayload = { resource: BaseResource; error: unknown };
19+
export type ResourceFetchPayload = { resource: BaseResource; status: 'idle' | 'fetching' };
1820

1921
type InternalEvents = {
2022
[events.TokenUpdate]: TokenUpdatePayload;
@@ -23,6 +25,7 @@ type InternalEvents = {
2325
[events.SessionTokenResolved]: null;
2426
[events.ResourceUpdate]: ResourceUpdatePayload;
2527
[events.ResourceError]: ResourceErrorPayload;
28+
[events.ResourceFetch]: ResourceFetchPayload;
2629
};
2730

2831
export const eventBus = createEventBus<InternalEvents>();

packages/clerk-js/src/core/resources/SignIn.ts

Lines changed: 23 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type {
2626
ResetPasswordParams,
2727
ResetPasswordPhoneCodeFactorConfig,
2828
SamlConfig,
29+
SetActiveNavigate,
2930
SignInCreateParams,
3031
SignInFirstFactor,
3132
SignInFutureResource,
@@ -58,6 +59,7 @@ import {
5859
webAuthnGetCredential as webAuthnGetCredentialOnWindow,
5960
} from '../../utils/passkeys';
6061
import { createValidatePassword } from '../../utils/passwords/password';
62+
import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask';
6163
import {
6264
clerkInvalidFAPIResponse,
6365
clerkInvalidStrategy,
@@ -493,8 +495,6 @@ class SignInFuture implements SignInFutureResource {
493495
submitPassword: this.submitResetPassword.bind(this),
494496
};
495497

496-
fetchStatus: 'idle' | 'fetching' = 'idle';
497-
498498
constructor(readonly resource: SignIn) {}
499499

500500
get status() {
@@ -506,8 +506,7 @@ class SignInFuture implements SignInFutureResource {
506506
}
507507

508508
async sendResetPasswordEmailCode(): Promise<{ error: unknown }> {
509-
eventBus.emit('resource:error', { resource: this.resource, error: null });
510-
try {
509+
return runAsyncResourceTask(this.resource, async () => {
511510
if (!this.resource.id) {
512511
throw new Error('Cannot reset password without a sign in.');
513512
}
@@ -525,27 +524,16 @@ class SignInFuture implements SignInFutureResource {
525524
body: { emailAddressId, strategy: 'reset_password_email_code' },
526525
action: 'prepare_first_factor',
527526
});
528-
} catch (err: unknown) {
529-
eventBus.emit('resource:error', { resource: this.resource, error: err });
530-
return { error: err };
531-
}
532-
533-
return { error: null };
527+
});
534528
}
535529

536530
async verifyResetPasswordEmailCode({ code }: { code: string }): Promise<{ error: unknown }> {
537-
eventBus.emit('resource:error', { resource: this.resource, error: null });
538-
try {
531+
return runAsyncResourceTask(this.resource, async () => {
539532
await this.resource.__internal_basePost({
540533
body: { code, strategy: 'reset_password_email_code' },
541534
action: 'attempt_first_factor',
542535
});
543-
} catch (err: unknown) {
544-
eventBus.emit('resource:error', { resource: this.resource, error: err });
545-
return { error: err };
546-
}
547-
548-
return { error: null };
536+
});
549537
}
550538

551539
async submitResetPassword({
@@ -555,18 +543,12 @@ class SignInFuture implements SignInFutureResource {
555543
password: string;
556544
signOutOfOtherSessions?: boolean;
557545
}): Promise<{ error: unknown }> {
558-
eventBus.emit('resource:error', { resource: this.resource, error: null });
559-
try {
546+
return runAsyncResourceTask(this.resource, async () => {
560547
await this.resource.__internal_basePost({
561548
body: { password, signOutOfOtherSessions },
562549
action: 'reset_password',
563550
});
564-
} catch (err: unknown) {
565-
eventBus.emit('resource:error', { resource: this.resource, error: err });
566-
return { error: err };
567-
}
568-
569-
return { error: null };
551+
});
570552
}
571553

572554
async create(params: {
@@ -575,39 +557,26 @@ class SignInFuture implements SignInFutureResource {
575557
redirectUrl?: string;
576558
actionCompleteRedirectUrl?: string;
577559
}): Promise<{ error: unknown }> {
578-
eventBus.emit('resource:error', { resource: this.resource, error: null });
579-
try {
560+
return runAsyncResourceTask(this.resource, async () => {
580561
await this.resource.__internal_basePost({
581562
path: this.resource.pathRoot,
582563
body: params,
583564
});
584-
585-
return { error: null };
586-
} catch (err: unknown) {
587-
eventBus.emit('resource:error', { resource: this.resource, error: err });
588-
return { error: err };
589-
}
565+
});
590566
}
591567

592568
async password({ identifier, password }: { identifier?: string; password: string }): Promise<{ error: unknown }> {
593-
eventBus.emit('resource:error', { resource: this.resource, error: null });
594-
const previousIdentifier = this.resource.identifier;
595-
try {
569+
return runAsyncResourceTask(this.resource, async () => {
570+
const previousIdentifier = this.resource.identifier;
596571
await this.resource.__internal_basePost({
597572
path: this.resource.pathRoot,
598573
body: { identifier: identifier || previousIdentifier, password },
599574
});
600-
} catch (err: unknown) {
601-
eventBus.emit('resource:error', { resource: this.resource, error: err });
602-
return { error: err };
603-
}
604-
605-
return { error: null };
575+
});
606576
}
607577

608578
async sendEmailCode({ email }: { email: string }): Promise<{ error: unknown }> {
609-
eventBus.emit('resource:error', { resource: this.resource, error: null });
610-
try {
579+
return runAsyncResourceTask(this.resource, async () => {
611580
if (!this.resource.id) {
612581
await this.create({ identifier: email });
613582
}
@@ -623,27 +592,16 @@ class SignInFuture implements SignInFutureResource {
623592
body: { emailAddressId, strategy: 'email_code' },
624593
action: 'prepare_first_factor',
625594
});
626-
} catch (err: unknown) {
627-
eventBus.emit('resource:error', { resource: this.resource, error: err });
628-
return { error: err };
629-
}
630-
631-
return { error: null };
595+
});
632596
}
633597

634598
async verifyEmailCode({ code }: { code: string }): Promise<{ error: unknown }> {
635-
eventBus.emit('resource:error', { resource: this.resource, error: null });
636-
try {
599+
return runAsyncResourceTask(this.resource, async () => {
637600
await this.resource.__internal_basePost({
638601
body: { code, strategy: 'email_code' },
639602
action: 'attempt_first_factor',
640603
});
641-
} catch (err: unknown) {
642-
eventBus.emit('resource:error', { resource: this.resource, error: err });
643-
return { error: err };
644-
}
645-
646-
return { error: null };
604+
});
647605
}
648606

649607
async sso({
@@ -657,8 +615,7 @@ class SignInFuture implements SignInFutureResource {
657615
redirectUrl: string;
658616
redirectUrlComplete: string;
659617
}): Promise<{ error: unknown }> {
660-
eventBus.emit('resource:error', { resource: this.resource, error: null });
661-
try {
618+
return runAsyncResourceTask(this.resource, async () => {
662619
if (flow !== 'auto') {
663620
throw new Error('modal flow is not supported yet');
664621
}
@@ -678,27 +635,16 @@ class SignInFuture implements SignInFutureResource {
678635
if (status === 'unverified' && externalVerificationRedirectURL) {
679636
windowNavigate(externalVerificationRedirectURL);
680637
}
681-
} catch (err: unknown) {
682-
eventBus.emit('resource:error', { resource: this.resource, error: err });
683-
return { error: err };
684-
}
685-
686-
return { error: null };
638+
});
687639
}
688640

689-
async finalize(): Promise<{ error: unknown }> {
690-
eventBus.emit('resource:error', { resource: this.resource, error: null });
691-
try {
641+
async finalize({ navigate }: { navigate?: SetActiveNavigate }): Promise<{ error: unknown }> {
642+
return runAsyncResourceTask(this.resource, async () => {
692643
if (!this.resource.createdSessionId) {
693644
throw new Error('Cannot finalize sign-in without a created session.');
694645
}
695646

696-
await SignIn.clerk.setActive({ session: this.resource.createdSessionId });
697-
} catch (err: unknown) {
698-
eventBus.emit('resource:error', { resource: this.resource, error: err });
699-
return { error: err };
700-
}
701-
702-
return { error: null };
647+
await SignIn.clerk.setActive({ session: this.resource.createdSessionId, navigate });
648+
});
703649
}
704650
}

packages/clerk-js/src/core/signals.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,18 @@ import { computed, signal } from 'alien-signals';
44

55
import type { SignIn } from './resources/SignIn';
66

7-
export const signInSignal = signal<{ resource: SignIn | null }>({ resource: null });
7+
export const signInResourceSignal = signal<{ resource: SignIn | null }>({ resource: null });
88
export const signInErrorSignal = signal<{ error: unknown }>({ error: null });
9+
export const signInFetchSignal = signal<{ status: 'idle' | 'fetching' }>({ status: 'idle' });
910

1011
export const signInComputedSignal = computed(() => {
11-
const signIn = signInSignal().resource;
12+
const signIn = signInResourceSignal().resource;
1213
const error = signInErrorSignal().error;
14+
const fetchStatus = signInFetchSignal().status;
1315

1416
const errors = errorsToParsedErrors(error);
1517

16-
if (!signIn) {
17-
return { errors, signIn: null };
18-
}
19-
20-
return { errors, signIn: signIn.__internal_future };
18+
return { errors, fetchStatus, signIn: signIn ? signIn.__internal_future : null };
2119
});
2220

2321
/**
@@ -42,6 +40,10 @@ function errorsToParsedErrors(error: unknown): Errors {
4240
global: [],
4341
};
4442

43+
if (!error) {
44+
return parsedErrors;
45+
}
46+
4547
if (!isClerkAPIResponseError(error)) {
4648
parsedErrors.raw.push(error);
4749
parsedErrors.global.push(error);

packages/clerk-js/src/core/state.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import { computed, effect } from 'alien-signals';
44
import { eventBus } from './events';
55
import type { BaseResource } from './resources/Base';
66
import { SignIn } from './resources/SignIn';
7-
import { signInComputedSignal, signInErrorSignal, signInSignal } from './signals';
7+
import { signInComputedSignal, signInErrorSignal, signInFetchSignal, signInResourceSignal } from './signals';
88

99
export class State implements StateInterface {
10-
signInResourceSignal = signInSignal;
10+
signInResourceSignal = signInResourceSignal;
1111
signInErrorSignal = signInErrorSignal;
12+
signInFetchSignal = signInFetchSignal;
1213
signInSignal = signInComputedSignal;
1314

1415
__internal_effect = effect;
@@ -17,6 +18,7 @@ export class State implements StateInterface {
1718
constructor() {
1819
eventBus.on('resource:update', this.onResourceUpdated);
1920
eventBus.on('resource:error', this.onResourceError);
21+
eventBus.on('resource:fetch', this.onResourceFetch);
2022
}
2123

2224
private onResourceError = (payload: { resource: BaseResource; error: unknown }) => {
@@ -30,4 +32,10 @@ export class State implements StateInterface {
3032
this.signInResourceSignal({ resource: payload.resource });
3133
}
3234
};
35+
36+
private onResourceFetch = (payload: { resource: BaseResource; status: 'idle' | 'fetching' }) => {
37+
if (payload.resource instanceof SignIn) {
38+
this.signInFetchSignal({ status: payload.status });
39+
}
40+
};
3341
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { eventBus } from '../../core/events';
4+
import { runAsyncResourceTask } from '../runAsyncResourceTask';
5+
6+
describe('runAsyncTask', () => {
7+
afterEach(() => {
8+
vi.restoreAllMocks();
9+
});
10+
11+
const resource = {} as any; // runAsyncTask doesn't depend on resource being a BaseResource
12+
13+
it('emits fetching/idle and returns result on success', async () => {
14+
const emitSpy = vi.spyOn(eventBus, 'emit');
15+
const task = vi.fn().mockResolvedValue('ok');
16+
17+
const { result, error } = await runAsyncResourceTask(resource, task);
18+
19+
expect(task).toHaveBeenCalledTimes(1);
20+
expect(result).toBe('ok');
21+
expect(error).toBeNull();
22+
23+
expect(emitSpy).toHaveBeenNthCalledWith(1, 'resource:error', {
24+
resource,
25+
error: null,
26+
});
27+
expect(emitSpy).toHaveBeenNthCalledWith(2, 'resource:fetch', {
28+
resource,
29+
status: 'fetching',
30+
});
31+
expect(emitSpy).toHaveBeenNthCalledWith(3, 'resource:fetch', {
32+
resource,
33+
status: 'idle',
34+
});
35+
});
36+
37+
it('emits error and returns error on failure', async () => {
38+
const emitSpy = vi.spyOn(eventBus, 'emit');
39+
const thrown = new Error('fail');
40+
const task = vi.fn().mockRejectedValue(thrown);
41+
42+
const { result, error } = await runAsyncResourceTask(resource, task);
43+
44+
expect(task).toHaveBeenCalledTimes(1);
45+
expect(result).toBeUndefined();
46+
expect(error).toBe(thrown);
47+
48+
expect(emitSpy).toHaveBeenNthCalledWith(1, 'resource:error', {
49+
resource,
50+
error: null,
51+
});
52+
expect(emitSpy).toHaveBeenNthCalledWith(2, 'resource:fetch', {
53+
resource,
54+
status: 'fetching',
55+
});
56+
expect(emitSpy).toHaveBeenNthCalledWith(3, 'resource:error', {
57+
resource,
58+
error: thrown,
59+
});
60+
expect(emitSpy).toHaveBeenNthCalledWith(4, 'resource:fetch', {
61+
resource,
62+
status: 'idle',
63+
});
64+
});
65+
});

0 commit comments

Comments
 (0)