Skip to content

Commit a1d10fc

Browse files
authored
feat(clerk-js,clerk-react,shared): Operation-specific errors fields (#7195)
1 parent d64638d commit a1d10fc

File tree

5 files changed

+173
-47
lines changed

5 files changed

+173
-47
lines changed

.changeset/clear-flowers-guess.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/shared': minor
4+
'@clerk/clerk-react': minor
5+
---
6+
7+
[Experimental] Update `errors` to have specific field types based on whether it's a sign-in or a sign-up.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { ClerkError } from '@clerk/shared/error';
2+
import { ClerkAPIResponseError } from '@clerk/shared/error';
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { errorsToParsedErrors } from '../signals';
6+
7+
describe('errorsToParsedErrors', () => {
8+
it('returns empty errors object when error is null', () => {
9+
const initialFields = { emailAddress: null, password: null };
10+
const result = errorsToParsedErrors(null, initialFields);
11+
12+
expect(result).toEqual({
13+
fields: { emailAddress: null, password: null },
14+
raw: null,
15+
global: null,
16+
});
17+
});
18+
19+
it('handles non-API errors by putting them in raw and global arrays', () => {
20+
const initialFields = { emailAddress: null, password: null };
21+
// Use a plain Error cast as ClerkError to test non-API error handling
22+
const error = new Error('Something went wrong') as unknown as ClerkError;
23+
const result = errorsToParsedErrors(error, initialFields);
24+
25+
expect(result.fields).toEqual({ emailAddress: null, password: null });
26+
expect(result.raw).toEqual([error]);
27+
expect(result.global).toBeTruthy();
28+
expect(result.global?.length).toBe(1);
29+
});
30+
31+
it('handles API errors with field errors', () => {
32+
const initialFields = { emailAddress: null, password: null };
33+
const error = new ClerkAPIResponseError('Validation failed', {
34+
data: [
35+
{
36+
code: 'form_identifier_not_found',
37+
message: 'emailAddress not found',
38+
meta: { param_name: 'emailAddress' },
39+
},
40+
],
41+
status: 400,
42+
});
43+
const result = errorsToParsedErrors(error, initialFields);
44+
45+
expect(result.fields.emailAddress).toBeTruthy();
46+
expect(result.fields.password).toBeNull();
47+
expect(result.raw).toEqual([error.errors[0]]);
48+
expect(result.global).toBeNull();
49+
});
50+
51+
it('handles API errors without field errors', () => {
52+
const initialFields = { emailAddress: null, password: null };
53+
const error = new ClerkAPIResponseError('Server error', {
54+
data: [
55+
{
56+
code: 'internal_error',
57+
message: 'Something went wrong on the server',
58+
},
59+
],
60+
status: 500,
61+
});
62+
const result = errorsToParsedErrors(error, initialFields);
63+
64+
expect(result.fields).toEqual({ emailAddress: null, password: null });
65+
// When there are no field errors, individual ClerkAPIError instances are put in raw
66+
expect(result.raw).toEqual([error.errors[0]]);
67+
// Note: global is null when errors are processed individually without field errors
68+
expect(result.global).toBeNull();
69+
});
70+
});

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

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type ClerkError, createClerkGlobalHookError, isClerkAPIResponseError } from '@clerk/shared/error';
2-
import type { Errors, SignInSignal, SignUpSignal } from '@clerk/shared/types';
2+
import type { Errors, SignInErrors, SignInSignal, SignUpErrors, SignUpSignal } from '@clerk/shared/types';
33
import { snakeToCamel } from '@clerk/shared/underscore';
44
import { computed, signal } from 'alien-signals';
55

@@ -15,7 +15,7 @@ export const signInComputedSignal: SignInSignal = computed(() => {
1515
const error = signInErrorSignal().error;
1616
const fetchStatus = signInFetchSignal().status;
1717

18-
const errors = errorsToParsedErrors(error);
18+
const errors = errorsToSignInErrors(error);
1919

2020
return { errors, fetchStatus, signIn: signIn ? signIn.__internal_future : null };
2121
});
@@ -29,7 +29,7 @@ export const signUpComputedSignal: SignUpSignal = computed(() => {
2929
const error = signUpErrorSignal().error;
3030
const fetchStatus = signUpFetchSignal().status;
3131

32-
const errors = errorsToParsedErrors(error);
32+
const errors = errorsToSignUpErrors(error);
3333

3434
return { errors, fetchStatus, signUp: signUp ? signUp.__internal_future : null };
3535
});
@@ -38,20 +38,12 @@ export const signUpComputedSignal: SignUpSignal = computed(() => {
3838
* Converts an error to a parsed errors object that reports the specific fields that the error pertains to. Will put
3939
* generic non-API errors into the global array.
4040
*/
41-
function errorsToParsedErrors(error: ClerkError | null): Errors {
42-
const parsedErrors: Errors = {
43-
fields: {
44-
firstName: null,
45-
lastName: null,
46-
emailAddress: null,
47-
identifier: null,
48-
phoneNumber: null,
49-
password: null,
50-
username: null,
51-
code: null,
52-
captcha: null,
53-
legalAccepted: null,
54-
},
41+
export function errorsToParsedErrors<T extends Record<string, unknown>>(
42+
error: ClerkError | null,
43+
initialFields: T,
44+
): Errors<T> {
45+
const parsedErrors: Errors<T> = {
46+
fields: { ...initialFields },
5547
raw: null,
5648
global: null,
5749
};
@@ -76,7 +68,9 @@ function errorsToParsedErrors(error: ClerkError | null): Errors {
7668
}
7769
if ('meta' in error && error.meta && 'paramName' in error.meta) {
7870
const name = snakeToCamel(error.meta.paramName);
79-
parsedErrors.fields[name as keyof typeof parsedErrors.fields] = error;
71+
if (name in parsedErrors.fields) {
72+
(parsedErrors.fields as any)[name] = error;
73+
}
8074
}
8175
// Note that this assumes a given ClerkAPIResponseError will only have either field errors or global errors, but
8276
// not both. If a global error is present, it will be discarded.
@@ -91,3 +85,25 @@ function errorsToParsedErrors(error: ClerkError | null): Errors {
9185

9286
return parsedErrors;
9387
}
88+
89+
function errorsToSignInErrors(error: ClerkError | null): SignInErrors {
90+
return errorsToParsedErrors(error, {
91+
identifier: null,
92+
password: null,
93+
code: null,
94+
});
95+
}
96+
97+
function errorsToSignUpErrors(error: ClerkError | null): SignUpErrors {
98+
return errorsToParsedErrors(error, {
99+
firstName: null,
100+
lastName: null,
101+
emailAddress: null,
102+
phoneNumber: null,
103+
password: null,
104+
username: null,
105+
code: null,
106+
captcha: null,
107+
legalAccepted: null,
108+
});
109+
}

packages/react/src/stateProxy.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
import { inBrowser } from '@clerk/shared/browser';
2-
import type { Errors, State } from '@clerk/shared/types';
2+
import type { SignInErrors, SignUpErrors, State } from '@clerk/shared/types';
33

44
import { errorThrower } from './errors/errorThrower';
55
import type { IsomorphicClerk } from './isomorphicClerk';
66

7-
const defaultErrors = (): Errors => ({
7+
const defaultSignInErrors = (): SignInErrors => ({
8+
fields: {
9+
identifier: null,
10+
password: null,
11+
code: null,
12+
},
13+
raw: null,
14+
global: null,
15+
});
16+
17+
const defaultSignUpErrors = (): SignUpErrors => ({
818
fields: {
919
firstName: null,
1020
lastName: null,
1121
emailAddress: null,
12-
identifier: null,
1322
phoneNumber: null,
1423
password: null,
1524
username: null,
@@ -39,7 +48,7 @@ export class StateProxy implements State {
3948
const target = () => this.client.signIn.__internal_future;
4049

4150
return {
42-
errors: defaultErrors(),
51+
errors: defaultSignInErrors(),
4352
fetchStatus: 'idle' as const,
4453
signIn: {
4554
status: 'needs_identifier' as const,
@@ -144,7 +153,7 @@ export class StateProxy implements State {
144153
const target = () => this.client.signUp.__internal_future;
145154

146155
return {
147-
errors: defaultErrors(),
156+
errors: defaultSignUpErrors(),
148157
fetchStatus: 'idle' as const,
149158
signUp: {
150159
get id() {

packages/shared/src/types/state.ts

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,46 @@ export interface FieldError {
2121
}
2222

2323
/**
24-
* Represents the collection of possible errors on known fields.
24+
* Represents the errors that occurred during the last fetch of the parent resource.
25+
*/
26+
export interface Errors<T> {
27+
/**
28+
* Represents the collection of possible errors on known fields.
29+
*/
30+
fields: T;
31+
/**
32+
* The raw, unparsed errors from the Clerk API.
33+
*/
34+
raw: unknown[] | null;
35+
/**
36+
* Parsed errors that are not related to any specific field.
37+
* Does not include any errors that could be parsed as a field error
38+
*/
39+
global: ClerkGlobalHookError[] | null;
40+
}
41+
42+
/**
43+
* Fields available for SignIn errors.
44+
*/
45+
export interface SignInFields {
46+
/**
47+
* The error for the identifier field.
48+
*/
49+
identifier: FieldError | null;
50+
/**
51+
* The error for the password field.
52+
*/
53+
password: FieldError | null;
54+
/**
55+
* The error for the code field.
56+
*/
57+
code: FieldError | null;
58+
}
59+
60+
/**
61+
* Fields available for SignUp errors.
2562
*/
26-
export interface FieldErrors {
63+
export interface SignUpFields {
2764
/**
2865
* The error for the first name field.
2966
*/
@@ -36,10 +73,6 @@ export interface FieldErrors {
3673
* The error for the email address field.
3774
*/
3875
emailAddress: FieldError | null;
39-
/**
40-
* The error for the identifier field.
41-
*/
42-
identifier: FieldError | null;
4376
/**
4477
* The error for the phone number field.
4578
*/
@@ -67,23 +100,14 @@ export interface FieldErrors {
67100
}
68101

69102
/**
70-
* Represents the errors that occurred during the last fetch of the parent resource.
103+
* Errors type for SignIn operations.
71104
*/
72-
export interface Errors {
73-
/**
74-
* Represents the collection of possible errors on known fields.
75-
*/
76-
fields: FieldErrors;
77-
/**
78-
* The raw, unparsed errors from the Clerk API.
79-
*/
80-
raw: unknown[] | null;
81-
/**
82-
* Parsed errors that are not related to any specific field.
83-
* Does not include any errors that could be parsed as a field error
84-
*/
85-
global: ClerkGlobalHookError[] | null;
86-
}
105+
export type SignInErrors = Errors<SignInFields>;
106+
107+
/**
108+
* Errors type for SignUp operations.
109+
*/
110+
export type SignUpErrors = Errors<SignUpFields>;
87111

88112
/**
89113
* The value returned by the `useSignInSignal` hook.
@@ -92,7 +116,7 @@ export interface SignInSignalValue {
92116
/**
93117
* Represents the errors that occurred during the last fetch of the parent resource.
94118
*/
95-
errors: Errors;
119+
errors: SignInErrors;
96120
/**
97121
* The fetch status of the underlying `SignIn` resource.
98122
*/
@@ -113,7 +137,7 @@ export interface SignUpSignalValue {
113137
/**
114138
* The errors that occurred during the last fetch of the underlying `SignUp` resource.
115139
*/
116-
errors: Errors;
140+
errors: SignUpErrors;
117141
/**
118142
* The fetch status of the underlying `SignUp` resource.
119143
*/

0 commit comments

Comments
 (0)