Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/clear-flowers-guess.md
Original file line number Diff line number Diff line change
@@ -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.
70 changes: 70 additions & 0 deletions packages/clerk-js/src/core/__tests__/signals.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
52 changes: 34 additions & 18 deletions packages/clerk-js/src/core/signals.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 };
});
Expand All @@ -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 };
});
Expand All @@ -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,
},
export function errorsToParsedErrors<T extends Record<string, unknown>>(
error: ClerkError | null,
initialFields: T,
): Errors<T> {
const parsedErrors: Errors<T> = {
fields: { ...initialFields },
raw: null,
global: null,
};
Expand All @@ -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;
}
Comment on lines +71 to +73
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace any with a type-safe assignment.

The use of any type casting violates the project's TypeScript guidelines, which require avoiding any types. While the guard at line 71 ensures the field exists, the type system isn't aware of this refinement.

Consider this type-safe alternative:

         const name = snakeToCamel(error.meta.paramName);
         if (name in parsedErrors.fields) {
-          (parsedErrors.fields as any)[name] = error;
+          parsedErrors.fields = {
+            ...parsedErrors.fields,
+            [name]: error,
+          } as T;
         }

Or use a more explicit approach with a helper function that preserves type safety:

function setField<T extends Record<string, unknown>>(fields: T, key: string, value: unknown): T {
  if (key in fields) {
    return { ...fields, [key]: value } as T;
  }
  return fields;
}
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/signals.ts around lines 71 to 73, the code uses an
unsafe (parsedErrors.fields as any)[name] assignment; replace this with a
type-safe update so we avoid any casts. Use a typed index signature or a small
generic helper: narrow parsedErrors.fields to the appropriate Record<string,
unknown> (or the exact field type) before assigning, or call a helper like
setField<T extends Record<string, unknown>>(fields, key, value) that returns a
new fields object with the updated key typed as T; ensure the helper or
narrowing preserves the original fields type and returns the updated object back
into parsedErrors.fields without using any.

}
// 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.
Expand All @@ -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,
});
Comment on lines +97 to +108
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Initialize all sign-up fields before parsing errors.

Once we add web3Wallet and unsafeMetadata to SignUpFields, we also need to seed them here. Otherwise the name in parsedErrors.fields guard still filters those errors out at runtime.

Please extend the initializer accordingly:

 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,
+    web3Wallet: null,
+    unsafeMetadata: null,
   });
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
});
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,
web3Wallet: null,
unsafeMetadata: null,
});
}
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/signals.ts around lines 97 to 108, the initializer
object passed to errorsToParsedErrors is missing the new SignUpFields keys
web3Wallet and unsafeMetadata, so those errors get filtered out at runtime;
update the initializer to include web3Wallet: null and unsafeMetadata: null
alongside the existing fields so all sign-up fields are seeded before parsing.

}
19 changes: 14 additions & 5 deletions packages/react/src/stateProxy.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down
72 changes: 48 additions & 24 deletions packages/shared/src/types/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
/**
* 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.
*/
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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<SignInFields>;

/**
* Errors type for SignUp operations.
*/
export type SignUpErrors = Errors<SignUpFields>;

/**
* The value returned by the `useSignInSignal` hook.
Expand All @@ -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.
*/
Expand All @@ -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.
*/
Expand Down
Loading