diff --git a/.changeset/twelve-crabs-return.md b/.changeset/twelve-crabs-return.md new file mode 100644 index 00000000000..4f618671b13 --- /dev/null +++ b/.changeset/twelve-crabs-return.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +[Experimental] Signal `fetchStatus` support. diff --git a/packages/clerk-js/src/core/events.ts b/packages/clerk-js/src/core/events.ts index 858aa6d10ee..6fb12dc2fa1 100644 --- a/packages/clerk-js/src/core/events.ts +++ b/packages/clerk-js/src/core/events.ts @@ -10,11 +10,13 @@ export const events = { SessionTokenResolved: 'session:tokenResolved', ResourceUpdate: 'resource:update', ResourceError: 'resource:error', + ResourceFetch: 'resource:fetch', } as const; type TokenUpdatePayload = { token: TokenResource | null }; export type ResourceUpdatePayload = { resource: BaseResource }; export type ResourceErrorPayload = { resource: BaseResource; error: unknown }; +export type ResourceFetchPayload = { resource: BaseResource; status: 'idle' | 'fetching' }; type InternalEvents = { [events.TokenUpdate]: TokenUpdatePayload; @@ -23,6 +25,7 @@ type InternalEvents = { [events.SessionTokenResolved]: null; [events.ResourceUpdate]: ResourceUpdatePayload; [events.ResourceError]: ResourceErrorPayload; + [events.ResourceFetch]: ResourceFetchPayload; }; export const eventBus = createEventBus(); diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index efda65c29ad..707b2b97261 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -26,6 +26,7 @@ import type { ResetPasswordParams, ResetPasswordPhoneCodeFactorConfig, SamlConfig, + SetActiveNavigate, SignInCreateParams, SignInFirstFactor, SignInFutureResource, @@ -58,6 +59,7 @@ import { webAuthnGetCredential as webAuthnGetCredentialOnWindow, } from '../../utils/passkeys'; import { createValidatePassword } from '../../utils/passwords/password'; +import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask'; import { clerkInvalidFAPIResponse, clerkInvalidStrategy, @@ -493,8 +495,6 @@ class SignInFuture implements SignInFutureResource { submitPassword: this.submitResetPassword.bind(this), }; - fetchStatus: 'idle' | 'fetching' = 'idle'; - constructor(readonly resource: SignIn) {} get status() { @@ -506,8 +506,7 @@ class SignInFuture implements SignInFutureResource { } async sendResetPasswordEmailCode(): Promise<{ error: unknown }> { - eventBus.emit('resource:error', { resource: this.resource, error: null }); - try { + return runAsyncResourceTask(this.resource, async () => { if (!this.resource.id) { throw new Error('Cannot reset password without a sign in.'); } @@ -525,27 +524,16 @@ class SignInFuture implements SignInFutureResource { body: { emailAddressId, strategy: 'reset_password_email_code' }, action: 'prepare_first_factor', }); - } catch (err: unknown) { - eventBus.emit('resource:error', { resource: this.resource, error: err }); - return { error: err }; - } - - return { error: null }; + }); } async verifyResetPasswordEmailCode({ code }: { code: string }): Promise<{ error: unknown }> { - eventBus.emit('resource:error', { resource: this.resource, error: null }); - try { + return runAsyncResourceTask(this.resource, async () => { await this.resource.__internal_basePost({ body: { code, strategy: 'reset_password_email_code' }, action: 'attempt_first_factor', }); - } catch (err: unknown) { - eventBus.emit('resource:error', { resource: this.resource, error: err }); - return { error: err }; - } - - return { error: null }; + }); } async submitResetPassword({ @@ -555,18 +543,12 @@ class SignInFuture implements SignInFutureResource { password: string; signOutOfOtherSessions?: boolean; }): Promise<{ error: unknown }> { - eventBus.emit('resource:error', { resource: this.resource, error: null }); - try { + return runAsyncResourceTask(this.resource, async () => { await this.resource.__internal_basePost({ body: { password, signOutOfOtherSessions }, action: 'reset_password', }); - } catch (err: unknown) { - eventBus.emit('resource:error', { resource: this.resource, error: err }); - return { error: err }; - } - - return { error: null }; + }); } async create(params: { @@ -575,39 +557,26 @@ class SignInFuture implements SignInFutureResource { redirectUrl?: string; actionCompleteRedirectUrl?: string; }): Promise<{ error: unknown }> { - eventBus.emit('resource:error', { resource: this.resource, error: null }); - try { + return runAsyncResourceTask(this.resource, async () => { await this.resource.__internal_basePost({ path: this.resource.pathRoot, body: params, }); - - return { error: null }; - } catch (err: unknown) { - eventBus.emit('resource:error', { resource: this.resource, error: err }); - return { error: err }; - } + }); } async password({ identifier, password }: { identifier?: string; password: string }): Promise<{ error: unknown }> { - eventBus.emit('resource:error', { resource: this.resource, error: null }); - const previousIdentifier = this.resource.identifier; - try { + return runAsyncResourceTask(this.resource, async () => { + const previousIdentifier = this.resource.identifier; await this.resource.__internal_basePost({ path: this.resource.pathRoot, body: { identifier: identifier || previousIdentifier, password }, }); - } catch (err: unknown) { - eventBus.emit('resource:error', { resource: this.resource, error: err }); - return { error: err }; - } - - return { error: null }; + }); } async sendEmailCode({ email }: { email: string }): Promise<{ error: unknown }> { - eventBus.emit('resource:error', { resource: this.resource, error: null }); - try { + return runAsyncResourceTask(this.resource, async () => { if (!this.resource.id) { await this.create({ identifier: email }); } @@ -623,27 +592,16 @@ class SignInFuture implements SignInFutureResource { body: { emailAddressId, strategy: 'email_code' }, action: 'prepare_first_factor', }); - } catch (err: unknown) { - eventBus.emit('resource:error', { resource: this.resource, error: err }); - return { error: err }; - } - - return { error: null }; + }); } async verifyEmailCode({ code }: { code: string }): Promise<{ error: unknown }> { - eventBus.emit('resource:error', { resource: this.resource, error: null }); - try { + return runAsyncResourceTask(this.resource, async () => { await this.resource.__internal_basePost({ body: { code, strategy: 'email_code' }, action: 'attempt_first_factor', }); - } catch (err: unknown) { - eventBus.emit('resource:error', { resource: this.resource, error: err }); - return { error: err }; - } - - return { error: null }; + }); } async sso({ @@ -657,8 +615,7 @@ class SignInFuture implements SignInFutureResource { redirectUrl: string; redirectUrlComplete: string; }): Promise<{ error: unknown }> { - eventBus.emit('resource:error', { resource: this.resource, error: null }); - try { + return runAsyncResourceTask(this.resource, async () => { if (flow !== 'auto') { throw new Error('modal flow is not supported yet'); } @@ -678,27 +635,16 @@ class SignInFuture implements SignInFutureResource { if (status === 'unverified' && externalVerificationRedirectURL) { windowNavigate(externalVerificationRedirectURL); } - } catch (err: unknown) { - eventBus.emit('resource:error', { resource: this.resource, error: err }); - return { error: err }; - } - - return { error: null }; + }); } - async finalize(): Promise<{ error: unknown }> { - eventBus.emit('resource:error', { resource: this.resource, error: null }); - try { + async finalize({ navigate }: { navigate?: SetActiveNavigate }): Promise<{ error: unknown }> { + return runAsyncResourceTask(this.resource, async () => { if (!this.resource.createdSessionId) { throw new Error('Cannot finalize sign-in without a created session.'); } - await SignIn.clerk.setActive({ session: this.resource.createdSessionId }); - } catch (err: unknown) { - eventBus.emit('resource:error', { resource: this.resource, error: err }); - return { error: err }; - } - - return { error: null }; + await SignIn.clerk.setActive({ session: this.resource.createdSessionId, navigate }); + }); } } diff --git a/packages/clerk-js/src/core/signals.ts b/packages/clerk-js/src/core/signals.ts index 5e10161485c..88cc6118cde 100644 --- a/packages/clerk-js/src/core/signals.ts +++ b/packages/clerk-js/src/core/signals.ts @@ -4,20 +4,18 @@ import { computed, signal } from 'alien-signals'; import type { SignIn } from './resources/SignIn'; -export const signInSignal = signal<{ resource: SignIn | null }>({ resource: null }); +export const signInResourceSignal = signal<{ resource: SignIn | null }>({ resource: null }); export const signInErrorSignal = signal<{ error: unknown }>({ error: null }); +export const signInFetchSignal = signal<{ status: 'idle' | 'fetching' }>({ status: 'idle' }); export const signInComputedSignal = computed(() => { - const signIn = signInSignal().resource; + const signIn = signInResourceSignal().resource; const error = signInErrorSignal().error; + const fetchStatus = signInFetchSignal().status; const errors = errorsToParsedErrors(error); - if (!signIn) { - return { errors, signIn: null }; - } - - return { errors, signIn: signIn.__internal_future }; + return { errors, fetchStatus, signIn: signIn ? signIn.__internal_future : null }; }); /** @@ -42,6 +40,10 @@ function errorsToParsedErrors(error: unknown): Errors { global: [], }; + if (!error) { + return parsedErrors; + } + if (!isClerkAPIResponseError(error)) { parsedErrors.raw.push(error); parsedErrors.global.push(error); diff --git a/packages/clerk-js/src/core/state.ts b/packages/clerk-js/src/core/state.ts index 94255e8a6e6..cdb778bc728 100644 --- a/packages/clerk-js/src/core/state.ts +++ b/packages/clerk-js/src/core/state.ts @@ -4,11 +4,12 @@ import { computed, effect } from 'alien-signals'; import { eventBus } from './events'; import type { BaseResource } from './resources/Base'; import { SignIn } from './resources/SignIn'; -import { signInComputedSignal, signInErrorSignal, signInSignal } from './signals'; +import { signInComputedSignal, signInErrorSignal, signInFetchSignal, signInResourceSignal } from './signals'; export class State implements StateInterface { - signInResourceSignal = signInSignal; + signInResourceSignal = signInResourceSignal; signInErrorSignal = signInErrorSignal; + signInFetchSignal = signInFetchSignal; signInSignal = signInComputedSignal; __internal_effect = effect; @@ -17,6 +18,7 @@ export class State implements StateInterface { constructor() { eventBus.on('resource:update', this.onResourceUpdated); eventBus.on('resource:error', this.onResourceError); + eventBus.on('resource:fetch', this.onResourceFetch); } private onResourceError = (payload: { resource: BaseResource; error: unknown }) => { @@ -30,4 +32,10 @@ export class State implements StateInterface { this.signInResourceSignal({ resource: payload.resource }); } }; + + private onResourceFetch = (payload: { resource: BaseResource; status: 'idle' | 'fetching' }) => { + if (payload.resource instanceof SignIn) { + this.signInFetchSignal({ status: payload.status }); + } + }; } diff --git a/packages/clerk-js/src/utils/__tests__/runAsyncResourceTask.spec.ts b/packages/clerk-js/src/utils/__tests__/runAsyncResourceTask.spec.ts new file mode 100644 index 00000000000..2fd83b89f76 --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/runAsyncResourceTask.spec.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { eventBus } from '../../core/events'; +import { runAsyncResourceTask } from '../runAsyncResourceTask'; + +describe('runAsyncTask', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const resource = {} as any; // runAsyncTask doesn't depend on resource being a BaseResource + + it('emits fetching/idle and returns result on success', async () => { + const emitSpy = vi.spyOn(eventBus, 'emit'); + const task = vi.fn().mockResolvedValue('ok'); + + const { result, error } = await runAsyncResourceTask(resource, task); + + expect(task).toHaveBeenCalledTimes(1); + expect(result).toBe('ok'); + expect(error).toBeNull(); + + expect(emitSpy).toHaveBeenNthCalledWith(1, 'resource:error', { + resource, + error: null, + }); + expect(emitSpy).toHaveBeenNthCalledWith(2, 'resource:fetch', { + resource, + status: 'fetching', + }); + expect(emitSpy).toHaveBeenNthCalledWith(3, 'resource:fetch', { + resource, + status: 'idle', + }); + }); + + it('emits error and returns error on failure', async () => { + const emitSpy = vi.spyOn(eventBus, 'emit'); + const thrown = new Error('fail'); + const task = vi.fn().mockRejectedValue(thrown); + + const { result, error } = await runAsyncResourceTask(resource, task); + + expect(task).toHaveBeenCalledTimes(1); + expect(result).toBeUndefined(); + expect(error).toBe(thrown); + + expect(emitSpy).toHaveBeenNthCalledWith(1, 'resource:error', { + resource, + error: null, + }); + expect(emitSpy).toHaveBeenNthCalledWith(2, 'resource:fetch', { + resource, + status: 'fetching', + }); + expect(emitSpy).toHaveBeenNthCalledWith(3, 'resource:error', { + resource, + error: thrown, + }); + expect(emitSpy).toHaveBeenNthCalledWith(4, 'resource:fetch', { + resource, + status: 'idle', + }); + }); +}); diff --git a/packages/clerk-js/src/utils/runAsyncResourceTask.ts b/packages/clerk-js/src/utils/runAsyncResourceTask.ts new file mode 100644 index 00000000000..c927573a388 --- /dev/null +++ b/packages/clerk-js/src/utils/runAsyncResourceTask.ts @@ -0,0 +1,30 @@ +import { eventBus } from '../core/events'; +import type { BaseResource } from '../core/resources/internal'; + +/** + * Wrap an async task with handling for emitting error and fetch events, which reduces boilerplate. Used in our Custom + * Flow APIs. + */ +export async function runAsyncResourceTask( + resource: BaseResource, + task: () => Promise, +): Promise<{ result?: T; error: unknown }> { + eventBus.emit('resource:error', { resource, error: null }); + eventBus.emit('resource:fetch', { + resource, + status: 'fetching', + }); + + try { + const result = await task(); + return { result, error: null }; + } catch (err) { + eventBus.emit('resource:error', { resource, error: err }); + return { error: err }; + } finally { + eventBus.emit('resource:fetch', { + resource, + status: 'idle', + }); + } +} diff --git a/packages/types/src/signIn.ts b/packages/types/src/signIn.ts index 7c7dc10b7de..f1f7ff23920 100644 --- a/packages/types/src/signIn.ts +++ b/packages/types/src/signIn.ts @@ -1,3 +1,4 @@ +import type { SetActiveNavigate } from './clerk'; import type { BackupCodeAttempt, BackupCodeFactor, @@ -126,7 +127,6 @@ export interface SignInResource extends ClerkResource { } export interface SignInFutureResource { - fetchStatus: 'idle' | 'fetching'; availableStrategies: SignInFirstFactor[]; status: SignInStatus | null; create: (params: { @@ -151,7 +151,7 @@ export interface SignInFutureResource { redirectUrl: string; redirectUrlComplete: string; }) => Promise<{ error: unknown }>; - finalize: () => Promise<{ error: unknown }>; + finalize: (params: { navigate?: SetActiveNavigate }) => Promise<{ error: unknown }>; } export type SignInStatus = diff --git a/packages/types/src/state.ts b/packages/types/src/state.ts index 200fe15cebd..007bd92f295 100644 --- a/packages/types/src/state.ts +++ b/packages/types/src/state.ts @@ -32,6 +32,7 @@ export interface State { signInSignal: { (): { errors: Errors; + fetchStatus: 'idle' | 'fetching'; signIn: SignInFutureResource | null; }; };