From 3b8c6757605d580a08520a69cf7b2b4a1661181a Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:50:21 -0600 Subject: [PATCH 1/2] feat(clerk-js,clerk-react,shared): Operation-specific errors fields --- .changeset/clear-flowers-guess.md | 7 +++ packages/clerk-js/src/core/signals.ts | 52 ++++++++++++------- packages/react/src/stateProxy.ts | 19 +++++-- packages/shared/src/types/state.ts | 72 ++++++++++++++++++--------- 4 files changed, 103 insertions(+), 47 deletions(-) create mode 100644 .changeset/clear-flowers-guess.md diff --git a/.changeset/clear-flowers-guess.md b/.changeset/clear-flowers-guess.md new file mode 100644 index 00000000000..23806e0bd18 --- /dev/null +++ b/.changeset/clear-flowers-guess.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/clerk-react': minor +--- + +[Experimental] Update `errors` to have specific field types based on whether it's a sign-in or a sign-up. diff --git a/packages/clerk-js/src/core/signals.ts b/packages/clerk-js/src/core/signals.ts index f8b238d3aa4..969f609c6a1 100644 --- a/packages/clerk-js/src/core/signals.ts +++ b/packages/clerk-js/src/core/signals.ts @@ -1,5 +1,5 @@ import { type ClerkError, createClerkGlobalHookError, isClerkAPIResponseError } from '@clerk/shared/error'; -import type { Errors, SignInSignal, SignUpSignal } from '@clerk/shared/types'; +import type { Errors, SignInErrors, SignInSignal, SignUpErrors, SignUpSignal } from '@clerk/shared/types'; import { snakeToCamel } from '@clerk/shared/underscore'; import { computed, signal } from 'alien-signals'; @@ -15,7 +15,7 @@ export const signInComputedSignal: SignInSignal = computed(() => { const error = signInErrorSignal().error; const fetchStatus = signInFetchSignal().status; - const errors = errorsToParsedErrors(error); + const errors = errorsToSignInErrors(error); return { errors, fetchStatus, signIn: signIn ? signIn.__internal_future : null }; }); @@ -29,7 +29,7 @@ export const signUpComputedSignal: SignUpSignal = computed(() => { const error = signUpErrorSignal().error; const fetchStatus = signUpFetchSignal().status; - const errors = errorsToParsedErrors(error); + const errors = errorsToSignUpErrors(error); return { errors, fetchStatus, signUp: signUp ? signUp.__internal_future : null }; }); @@ -38,20 +38,12 @@ export const signUpComputedSignal: SignUpSignal = computed(() => { * Converts an error to a parsed errors object that reports the specific fields that the error pertains to. Will put * generic non-API errors into the global array. */ -function errorsToParsedErrors(error: ClerkError | null): Errors { - const parsedErrors: Errors = { - fields: { - firstName: null, - lastName: null, - emailAddress: null, - identifier: null, - phoneNumber: null, - password: null, - username: null, - code: null, - captcha: null, - legalAccepted: null, - }, +function errorsToParsedErrors>( + error: ClerkError | null, + initialFields: T, +): Errors { + const parsedErrors: Errors = { + fields: { ...initialFields }, raw: null, global: null, }; @@ -76,7 +68,9 @@ function errorsToParsedErrors(error: ClerkError | null): Errors { } if ('meta' in error && error.meta && 'paramName' in error.meta) { const name = snakeToCamel(error.meta.paramName); - parsedErrors.fields[name as keyof typeof parsedErrors.fields] = error; + if (name in parsedErrors.fields) { + (parsedErrors.fields as any)[name] = error; + } } // Note that this assumes a given ClerkAPIResponseError will only have either field errors or global errors, but // not both. If a global error is present, it will be discarded. @@ -91,3 +85,25 @@ function errorsToParsedErrors(error: ClerkError | null): Errors { return parsedErrors; } + +function errorsToSignInErrors(error: ClerkError | null): SignInErrors { + return errorsToParsedErrors(error, { + identifier: null, + password: null, + code: null, + }); +} + +function errorsToSignUpErrors(error: ClerkError | null): SignUpErrors { + return errorsToParsedErrors(error, { + firstName: null, + lastName: null, + emailAddress: null, + phoneNumber: null, + password: null, + username: null, + code: null, + captcha: null, + legalAccepted: null, + }); +} diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index dcb8c59cf0e..d57417ae152 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -1,15 +1,24 @@ import { inBrowser } from '@clerk/shared/browser'; -import type { Errors, State } from '@clerk/shared/types'; +import type { SignInErrors, SignUpErrors, State } from '@clerk/shared/types'; import { errorThrower } from './errors/errorThrower'; import type { IsomorphicClerk } from './isomorphicClerk'; -const defaultErrors = (): Errors => ({ +const defaultSignInErrors = (): SignInErrors => ({ + fields: { + identifier: null, + password: null, + code: null, + }, + raw: null, + global: null, +}); + +const defaultSignUpErrors = (): SignUpErrors => ({ fields: { firstName: null, lastName: null, emailAddress: null, - identifier: null, phoneNumber: null, password: null, username: null, @@ -39,7 +48,7 @@ export class StateProxy implements State { const target = () => this.client.signIn.__internal_future; return { - errors: defaultErrors(), + errors: defaultSignInErrors(), fetchStatus: 'idle' as const, signIn: { status: 'needs_identifier' as const, @@ -144,7 +153,7 @@ export class StateProxy implements State { const target = () => this.client.signUp.__internal_future; return { - errors: defaultErrors(), + errors: defaultSignUpErrors(), fetchStatus: 'idle' as const, signUp: { get id() { diff --git a/packages/shared/src/types/state.ts b/packages/shared/src/types/state.ts index 6fe794cbd1e..0a36fb1e6a4 100644 --- a/packages/shared/src/types/state.ts +++ b/packages/shared/src/types/state.ts @@ -21,9 +21,46 @@ export interface FieldError { } /** - * Represents the collection of possible errors on known fields. + * Represents the errors that occurred during the last fetch of the parent resource. + */ +export interface Errors { + /** + * Represents the collection of possible errors on known fields. + */ + fields: T; + /** + * The raw, unparsed errors from the Clerk API. + */ + raw: unknown[] | null; + /** + * Parsed errors that are not related to any specific field. + * Does not include any errors that could be parsed as a field error + */ + global: ClerkGlobalHookError[] | null; +} + +/** + * Fields available for SignIn errors. + */ +export interface SignInFields { + /** + * The error for the identifier field. + */ + identifier: FieldError | null; + /** + * The error for the password field. + */ + password: FieldError | null; + /** + * The error for the code field. + */ + code: FieldError | null; +} + +/** + * Fields available for SignUp errors. */ -export interface FieldErrors { +export interface SignUpFields { /** * The error for the first name field. */ @@ -36,10 +73,6 @@ export interface FieldErrors { * The error for the email address field. */ emailAddress: FieldError | null; - /** - * The error for the identifier field. - */ - identifier: FieldError | null; /** * The error for the phone number field. */ @@ -67,23 +100,14 @@ export interface FieldErrors { } /** - * Represents the errors that occurred during the last fetch of the parent resource. + * Errors type for SignIn operations. */ -export interface Errors { - /** - * Represents the collection of possible errors on known fields. - */ - fields: FieldErrors; - /** - * The raw, unparsed errors from the Clerk API. - */ - raw: unknown[] | null; - /** - * Parsed errors that are not related to any specific field. - * Does not include any errors that could be parsed as a field error - */ - global: ClerkGlobalHookError[] | null; -} +export type SignInErrors = Errors; + +/** + * Errors type for SignUp operations. + */ +export type SignUpErrors = Errors; /** * The value returned by the `useSignInSignal` hook. @@ -92,7 +116,7 @@ export interface SignInSignalValue { /** * Represents the errors that occurred during the last fetch of the parent resource. */ - errors: Errors; + errors: SignInErrors; /** * The fetch status of the underlying `SignIn` resource. */ @@ -113,7 +137,7 @@ export interface SignUpSignalValue { /** * The errors that occurred during the last fetch of the underlying `SignUp` resource. */ - errors: Errors; + errors: SignUpErrors; /** * The fetch status of the underlying `SignUp` resource. */ From 8b52a199a93058ca3bc89c7378ffcd753ceb35b9 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:00:18 -0600 Subject: [PATCH 2/2] test(clerk-js): Add basic errorsToParsedErrors test --- .../src/core/__tests__/signals.test.ts | 70 +++++++++++++++++++ packages/clerk-js/src/core/signals.ts | 2 +- 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 packages/clerk-js/src/core/__tests__/signals.test.ts diff --git a/packages/clerk-js/src/core/__tests__/signals.test.ts b/packages/clerk-js/src/core/__tests__/signals.test.ts new file mode 100644 index 00000000000..b7b8eef587f --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/signals.test.ts @@ -0,0 +1,70 @@ +import type { ClerkError } from '@clerk/shared/error'; +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { describe, expect, it } from 'vitest'; + +import { errorsToParsedErrors } from '../signals'; + +describe('errorsToParsedErrors', () => { + it('returns empty errors object when error is null', () => { + const initialFields = { emailAddress: null, password: null }; + const result = errorsToParsedErrors(null, initialFields); + + expect(result).toEqual({ + fields: { emailAddress: null, password: null }, + raw: null, + global: null, + }); + }); + + it('handles non-API errors by putting them in raw and global arrays', () => { + const initialFields = { emailAddress: null, password: null }; + // Use a plain Error cast as ClerkError to test non-API error handling + const error = new Error('Something went wrong') as unknown as ClerkError; + const result = errorsToParsedErrors(error, initialFields); + + expect(result.fields).toEqual({ emailAddress: null, password: null }); + expect(result.raw).toEqual([error]); + expect(result.global).toBeTruthy(); + expect(result.global?.length).toBe(1); + }); + + it('handles API errors with field errors', () => { + const initialFields = { emailAddress: null, password: null }; + const error = new ClerkAPIResponseError('Validation failed', { + data: [ + { + code: 'form_identifier_not_found', + message: 'emailAddress not found', + meta: { param_name: 'emailAddress' }, + }, + ], + status: 400, + }); + const result = errorsToParsedErrors(error, initialFields); + + expect(result.fields.emailAddress).toBeTruthy(); + expect(result.fields.password).toBeNull(); + expect(result.raw).toEqual([error.errors[0]]); + expect(result.global).toBeNull(); + }); + + it('handles API errors without field errors', () => { + const initialFields = { emailAddress: null, password: null }; + const error = new ClerkAPIResponseError('Server error', { + data: [ + { + code: 'internal_error', + message: 'Something went wrong on the server', + }, + ], + status: 500, + }); + const result = errorsToParsedErrors(error, initialFields); + + expect(result.fields).toEqual({ emailAddress: null, password: null }); + // When there are no field errors, individual ClerkAPIError instances are put in raw + expect(result.raw).toEqual([error.errors[0]]); + // Note: global is null when errors are processed individually without field errors + expect(result.global).toBeNull(); + }); +}); diff --git a/packages/clerk-js/src/core/signals.ts b/packages/clerk-js/src/core/signals.ts index 969f609c6a1..7d0310161db 100644 --- a/packages/clerk-js/src/core/signals.ts +++ b/packages/clerk-js/src/core/signals.ts @@ -38,7 +38,7 @@ export const signUpComputedSignal: SignUpSignal = computed(() => { * Converts an error to a parsed errors object that reports the specific fields that the error pertains to. Will put * generic non-API errors into the global array. */ -function errorsToParsedErrors>( +export function errorsToParsedErrors>( error: ClerkError | null, initialFields: T, ): Errors {