Skip to content

feat(clerk-js,clerk-react,types): Signal errors #6495

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Aug 11, 2025
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/happy-dodos-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/clerk-js': minor
'@clerk/clerk-react': minor
'@clerk/types': minor
---

[Experimental] Signal Errors
4 changes: 2 additions & 2 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"files": [
{ "path": "./dist/clerk.js", "maxSize": "621KB" },
{ "path": "./dist/clerk.js", "maxSize": "622KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "75KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "57.1KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "58KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "113KB" },
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "118KB" },
{ "path": "./dist/vendors*.js", "maxSize": "40.2KB" },
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ class SignInFuture implements SignInFutureResource {
});

return { error: null };
} catch (err) {
} catch (err: unknown) {
eventBus.emit('resource:error', { resource: this.resource, error: err });
return { error: err };
}
Expand All @@ -530,7 +530,7 @@ class SignInFuture implements SignInFutureResource {
path: this.resource.pathRoot,
body: { identifier, password },
});
} catch (err) {
} catch (err: unknown) {
eventBus.emit('resource:error', { resource: this.resource, error: err });
return { error: err };
}
Expand Down
85 changes: 82 additions & 3 deletions packages/clerk-js/src/core/signals.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,96 @@
import { isClerkAPIResponseError } from '@clerk/shared/error';
import type { Errors } from '@clerk/types';
import { computed, signal } from 'alien-signals';

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

export const signInSignal = signal<{ resource: SignIn | null }>({ resource: null });
export const signInErrorSignal = signal<{ errors: unknown }>({ errors: null });
export const signInErrorSignal = signal<{ error: unknown }>({ error: null });

export const signInComputedSignal = computed(() => {
const signIn = signInSignal().resource;
const errors = signInErrorSignal().errors;
const error = signInErrorSignal().error;

const errors = errorsToParsedErrors(error);

if (!signIn) {
return { errors: null, signIn: null };
return { errors, signIn: null };
}

return { errors, signIn: signIn.__internal_future };
});

/**
* 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: unknown): 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,
},
raw: [],
global: [],
};

if (!isClerkAPIResponseError(error)) {
parsedErrors.raw.push(error);
parsedErrors.global.push(error);
return parsedErrors;
}

parsedErrors.raw.push(...error.errors);

error.errors.forEach(error => {
if ('meta' in error && error.meta && 'paramName' in error.meta) {
switch (error.meta.paramName) {
case 'first_name':
parsedErrors.fields.firstName = error;
break;
case 'last_name':
parsedErrors.fields.lastName = error;
break;
case 'email_address':
parsedErrors.fields.emailAddress = error;
break;
case 'identifier':
parsedErrors.fields.identifier = error;
break;
case 'phone_number':
parsedErrors.fields.phoneNumber = error;
break;
case 'password':
parsedErrors.fields.password = error;
break;
case 'username':
parsedErrors.fields.username = error;
break;
case 'code':
parsedErrors.fields.code = error;
break;
case 'captcha':
parsedErrors.fields.captcha = error;
break;
case 'legal_accepted':
parsedErrors.fields.legalAccepted = error;
break;
default:
parsedErrors.global.push(error);
break;
}
Comment on lines +53 to +89
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

Field errors may be overwritten when multiple errors exist for the same field

The current implementation assigns errors directly to field properties (e.g., parsedErrors.fields.firstName = error). If the API returns multiple errors for the same field, only the last one will be retained, potentially losing important validation information.

Consider either:

  1. Changing field properties to arrays to store multiple errors per field
  2. Documenting this limitation if it's intentional
  3. Logging a warning when overwriting occurs

Example of potential data loss:

// If API returns two errors for 'first_name':
// Error 1: "First name is required"
// Error 2: "First name contains invalid characters"
// Only Error 2 would be retained in parsedErrors.fields.firstName
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/signals.ts around lines 53 to 89, the code assigns
a single error object to each field which overwrites earlier errors for the same
field; change each field property to hold an array of errors (or, if the
surrounding types must remain, convert to an array when a second error appears)
and push errors instead of assigning so all errors are preserved; update the
parsedErrors initialization and related types/interfaces to use arrays for
fields (or handle conversion on conflict), and ensure downstream callers
expect/handle arrays (or convert them back if necessary).

} else {
parsedErrors.global.push(error);
}
});

return parsedErrors;
}
Comment on lines +27 to +96
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add unit tests for the new error parsing functionality

The errorsToParsedErrors function introduces complex error parsing logic with multiple branches and field mappings. Please add comprehensive unit tests to cover:

  • Non-Clerk API errors
  • Clerk API errors with various paramName values
  • Errors without meta or paramName properties
  • Multiple errors for the same field
  • Unknown paramName values hitting the default case
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/signals.ts around lines 27 to 96, add
comprehensive unit tests for errorsToParsedErrors to validate its branching and
field mapping logic: create test cases for (1) non-Clerk errors (ensure raw and
global contain the original error), (2) Clerk API errors with each supported
paramName (first_name, last_name, email_address, identifier, phone_number,
password, username, code, captcha, legal_accepted) mapping to the correct
fields, (3) errors missing meta or paramName (expect them in global), (4)
multiple errors for the same field (ensure the last/expected assignment or
accumulation behavior asserted), and (5) unknown paramName values hitting the
default case (assigned to global); implement assertions on parsedErrors.fields,
parsedErrors.raw, and parsedErrors.global for each case.

2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class State implements StateInterface {

private onResourceError = (payload: { resource: BaseResource; error: unknown }) => {
if (payload.resource instanceof SignIn) {
this.signInErrorSignal({ errors: payload.error });
this.signInErrorSignal({ error: payload.error });
}
};

Expand Down
3 changes: 1 addition & 2 deletions packages/react/src/hooks/useClerkSignal.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type { SignInFutureResource } from '@clerk/types';
import { useCallback, useSyncExternalStore } from 'react';

import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext';
import { useAssertWrappedByClerkProvider } from './useAssertWrappedByClerkProvider';

function useClerkSignal(signal: 'signIn'): { errors: unknown; signIn: SignInFutureResource | null } | null {
function useClerkSignal(signal: 'signIn') {
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Restore explicit return types for public APIs

Per guidelines, exported hooks should have explicit return types. You can capture the core’s type without duplicating shapes via ReturnType of the State['signInSignal'].

+import type { State as ClerkState } from '@clerk/types';
 
-function useClerkSignal(signal: 'signIn') {
+function useClerkSignal(signal: 'signIn'): ReturnType<ClerkState['signInSignal']> | null {
   …
 }
 
-export function useSignInSignal() {
+export function useSignInSignal(): ReturnType<ClerkState['signInSignal']> | null {
   return useClerkSignal('signIn');
 }
📝 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 useClerkSignal(signal: 'signIn') {
// Add the type import
import type { State as ClerkState } from '@clerk/types';
function useClerkSignal(
signal: 'signIn'
): ReturnType<ClerkState['signInSignal']> | null {
}
export function useSignInSignal(): ReturnType<ClerkState['signInSignal']> | null {
return useClerkSignal('signIn');
}
🤖 Prompt for AI Agents
In packages/react/src/hooks/useClerkSignal.ts at line 6, the exported hook
useClerkSignal lacks an explicit return type. Fix this by adding an explicit
return type using ReturnType<typeof State['signInSignal']> to capture the core
type without duplicating shapes, ensuring the public API follows the guidelines.

useAssertWrappedByClerkProvider('useSignInSignal');

const clerk = useIsomorphicClerkContext();
Expand Down
28 changes: 26 additions & 2 deletions packages/types/src/state.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
import type { SignInFutureResource } from './signIn';

interface FieldError {
code: string;
longMessage?: string;
message: string;
}

interface FieldErrors {
firstName: FieldError | null;
lastName: FieldError | null;
emailAddress: FieldError | null;
identifier: FieldError | null;
phoneNumber: FieldError | null;
password: FieldError | null;
username: FieldError | null;
code: FieldError | null;
captcha: FieldError | null;
legalAccepted: FieldError | null;
}

export interface Errors {
fields: FieldErrors;
raw: unknown[];
global: unknown[]; // does not include any errors that could be parsed as a field error
}

export interface State {
/**
* A Signal that updates when the underlying `SignIn` resource changes, including errors.
*/
signInSignal: {
(): {
errors: unknown;
errors: Errors;
signIn: SignInFutureResource | null;
};
(value: { errors: unknown; signIn: SignInFutureResource | null }): void;
};

/**
Expand Down