From 11e8cba14fb558a4746e700d25a80ff08677cc50 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:06:42 -0500 Subject: [PATCH 1/3] feat(clerk-js,clerk-react,types): Update signal hooks to always return values --- .changeset/rotten-falcons-cover.md | 7 ++ .../clerk-js/src/core/resources/SignIn.ts | 4 +- .../clerk-js/src/core/resources/SignUp.ts | 4 +- packages/react/src/hooks/useClerkSignal.ts | 32 +++--- packages/react/src/isomorphicClerk.ts | 7 +- packages/react/src/stateProxy.ts | 108 ++++++++++++++++++ packages/types/src/clerk.ts | 2 +- packages/types/src/signIn.ts | 7 +- packages/types/src/signUp.ts | 7 +- 9 files changed, 155 insertions(+), 23 deletions(-) create mode 100644 .changeset/rotten-falcons-cover.md create mode 100644 packages/react/src/stateProxy.ts diff --git a/.changeset/rotten-falcons-cover.md b/.changeset/rotten-falcons-cover.md new file mode 100644 index 00000000000..5ee2b410521 --- /dev/null +++ b/.changeset/rotten-falcons-cover.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +[Experimental] Signals `isLoaded` removal diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 707b2b97261..27ce090e8f0 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -91,7 +91,7 @@ export class SignIn extends BaseResource implements SignInResource { * * An instance of `SignInFuture`, which has a different API than `SignIn`, intended to be used in custom flows. */ - __internal_future: SignInFuture | null = new SignInFuture(this); + __internal_future: SignInFuture = new SignInFuture(this); /** * @internal Only used for internal purposes, and is not intended to be used directly. @@ -638,7 +638,7 @@ class SignInFuture implements SignInFutureResource { }); } - async finalize({ navigate }: { navigate?: SetActiveNavigate }): Promise<{ error: unknown }> { + 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.'); diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 8f9aee5b12e..0b2c8bd7bca 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -87,7 +87,7 @@ export class SignUp extends BaseResource implements SignUpResource { * * An instance of `SignUpFuture`, which has a different API than `SignUp`, intended to be used in custom flows. */ - __internal_future: SignUpFuture | null = new SignUpFuture(this); + __internal_future: SignUpFuture = new SignUpFuture(this); /** * @internal Only used for internal purposes, and is not intended to be used directly. @@ -539,7 +539,7 @@ class SignUpFuture implements SignUpFutureResource { }); } - async finalize({ navigate }: { navigate?: SetActiveNavigate }): Promise<{ error: unknown }> { + async finalize({ navigate }: { navigate?: SetActiveNavigate } = {}): Promise<{ error: unknown }> { return runAsyncResourceTask(this.resource, async () => { if (!this.resource.createdSessionId) { throw new Error('Cannot finalize sign-up without a created session.'); diff --git a/packages/react/src/hooks/useClerkSignal.ts b/packages/react/src/hooks/useClerkSignal.ts index ceb8f3d9393..73c02777951 100644 --- a/packages/react/src/hooks/useClerkSignal.ts +++ b/packages/react/src/hooks/useClerkSignal.ts @@ -4,28 +4,36 @@ import { useCallback, useSyncExternalStore } from 'react'; import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext'; import { useAssertWrappedByClerkProvider } from './useAssertWrappedByClerkProvider'; -function useClerkSignal(signal: 'signIn'): ReturnType | null; -function useClerkSignal(signal: 'signUp'): ReturnType | null; -function useClerkSignal(signal: 'signIn' | 'signUp'): ReturnType | ReturnType | null { +// These types are used to remove the `null` value from the underlying resource. This is safe because IsomorphicClerk +// always returns a valid resource, even before Clerk is loaded, and if Clerk is loaded, the resource is guaranteed to +// be non-null +type NonNullSignInSignal = Omit, 'signIn'> & { + signIn: NonNullable['signIn']>; +}; +type NonNullSignUpSignal = Omit, 'signUp'> & { + signUp: NonNullable['signUp']>; +}; + +function useClerkSignal(signal: 'signIn'): NonNullSignInSignal; +function useClerkSignal(signal: 'signUp'): NonNullSignUpSignal; +function useClerkSignal(signal: 'signIn' | 'signUp'): NonNullSignInSignal | NonNullSignUpSignal { useAssertWrappedByClerkProvider('useClerkSignal'); const clerk = useIsomorphicClerkContext(); const subscribe = useCallback( (callback: () => void) => { - if (!clerk.loaded || !clerk.__internal_state) { + if (!clerk.loaded) { return () => {}; } return clerk.__internal_state.__internal_effect(() => { switch (signal) { case 'signIn': - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know that the state is defined - clerk.__internal_state!.signInSignal(); + clerk.__internal_state.signInSignal(); break; case 'signUp': - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know that the state is defined - clerk.__internal_state!.signUpSignal(); + clerk.__internal_state.signUpSignal(); break; default: throw new Error(`Unknown signal: ${signal}`); @@ -36,15 +44,11 @@ function useClerkSignal(signal: 'signIn' | 'signUp'): ReturnType | [clerk, clerk.loaded, clerk.__internal_state], ); const getSnapshot = useCallback(() => { - if (!clerk.__internal_state) { - return null; - } - switch (signal) { case 'signIn': - return clerk.__internal_state.signInSignal(); + return clerk.__internal_state.signInSignal() as NonNullSignInSignal; case 'signUp': - return clerk.__internal_state.signUpSignal(); + return clerk.__internal_state.signUpSignal() as NonNullSignUpSignal; default: throw new Error(`Unknown signal: ${signal}`); } diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 1e94bc0a6c9..e1b6290984f 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -56,6 +56,7 @@ import type { import { errorThrower } from './errors/errorThrower'; import { unsupportedNonBrowserDomainOrProxyUrlFunction } from './errors/messages'; +import { StateProxy } from './stateProxy'; import type { BrowserClerk, BrowserClerkConstructor, @@ -158,6 +159,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { #proxyUrl: DomainOrProxyUrl['proxyUrl']; #publishableKey: string; #eventBus = createClerkEventBus(); + #stateProxy: StateProxy; get publishableKey(): string { return this.#publishableKey; @@ -250,6 +252,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { this.options = options; this.Clerk = Clerk; this.mode = inBrowser() ? 'browser' : 'server'; + this.#stateProxy = new StateProxy(this); if (!this.options.sdkMetadata) { this.options.sdkMetadata = SDK_METADATA; @@ -723,8 +726,8 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return this.clerkjs?.billing; } - get __internal_state(): State | undefined { - return this.clerkjs?.__internal_state; + get __internal_state(): State { + return this.loaded && this.clerkjs ? this.clerkjs.__internal_state : this.#stateProxy; } get apiKeys(): APIKeysNamespace | undefined { diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts new file mode 100644 index 00000000000..9c027aad4d4 --- /dev/null +++ b/packages/react/src/stateProxy.ts @@ -0,0 +1,108 @@ +import type { Errors, State } from '@clerk/types'; + +import type { IsomorphicClerk } from './isomorphicClerk'; + +const defaultErrors = (): Errors => ({ + fields: { + firstName: null, + lastName: null, + emailAddress: null, + identifier: null, + phoneNumber: null, + password: null, + username: null, + code: null, + captcha: null, + legalAccepted: null, + }, + raw: [], + global: [], +}); + +export class StateProxy implements State { + constructor(private isomorphicClerk: IsomorphicClerk) {} + + private readonly signInSignalProxy = this.buildSignInProxy(); + private readonly signUpSignalProxy = this.buildSignUpProxy(); + + signInSignal() { + return this.signInSignalProxy; + } + signUpSignal() { + return this.signUpSignalProxy; + } + + private buildSignInProxy() { + const target = () => this.client.signIn.__internal_future; + + return { + errors: defaultErrors(), + fetchStatus: 'idle' as const, + signIn: { + status: 'needs_identifier' as const, + availableStrategies: [], + + create: this.gateMethod(target, 'create'), + password: this.gateMethod(target, 'password'), + sso: this.gateMethod(target, 'sso'), + finalize: this.gateMethod(target, 'finalize'), + + emailCode: this.wrapMethods(() => target().emailCode, ['sendCode', 'verifyCode'] as const), + resetPasswordEmailCode: this.wrapMethods(() => target().resetPasswordEmailCode, [ + 'sendCode', + 'verifyCode', + 'submitPassword', + ] as const), + }, + }; + } + + private buildSignUpProxy() { + const target = () => this.client.signUp.__internal_future; + + return { + errors: defaultErrors(), + fetchStatus: 'idle' as const, + signUp: { + status: 'missing_requirements' as const, + unverifiedFields: [], + + password: this.gateMethod(target, 'password'), + finalize: this.gateMethod(target, 'finalize'), + + verifications: this.wrapMethods(() => target().verifications, ['sendEmailCode', 'verifyEmailCode'] as const), + }, + }; + } + + __internal_effect(_: () => void): () => void { + throw new Error('__internal_effect called before Clerk is loaded'); + } + __internal_computed(_: (prev?: T) => T): () => T { + throw new Error('__internal_computed called before Clerk is loaded'); + } + + private get client() { + const c = this.isomorphicClerk.client; + if (!c) throw new Error('Clerk client not ready'); + return c; + } + + private gateMethod(getTarget: () => T, key: K) { + type F = Extract unknown>; + return (async (...args: Parameters): Promise> => { + if (!this.isomorphicClerk.loaded) { + await new Promise(resolve => this.isomorphicClerk.addOnLoaded(resolve)); + } + const t = getTarget(); + return (t[key] as (...args: Parameters) => ReturnType).apply(t, args); + }) as F; + } + + private wrapMethods( + getTarget: () => T, + keys: K, + ): Pick { + return Object.fromEntries(keys.map(k => [k, this.gateMethod(getTarget, k)])) as Pick; + } +} diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 356c3dc741e..43166b9768c 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -235,7 +235,7 @@ export interface Clerk { * Entrypoint for Clerk's Signal API containing resource signals along with accessible versions of `computed()` and * `effect()` that can be used to subscribe to changes from Signals. */ - __internal_state: State | undefined; + __internal_state: State; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. diff --git a/packages/types/src/signIn.ts b/packages/types/src/signIn.ts index f1f7ff23920..582c499ab6d 100644 --- a/packages/types/src/signIn.ts +++ b/packages/types/src/signIn.ts @@ -124,6 +124,11 @@ export interface SignInResource extends ClerkResource { * @internal */ __internal_toSnapshot: () => SignInJSONSnapshot; + + /** + * @internal + */ + __internal_future: SignInFutureResource; } export interface SignInFutureResource { @@ -151,7 +156,7 @@ export interface SignInFutureResource { redirectUrl: string; redirectUrlComplete: string; }) => Promise<{ error: unknown }>; - finalize: (params: { navigate?: SetActiveNavigate }) => Promise<{ error: unknown }>; + finalize: (params?: { navigate?: SetActiveNavigate }) => Promise<{ error: unknown }>; } export type SignInStatus = diff --git a/packages/types/src/signUp.ts b/packages/types/src/signUp.ts index eded9a32f1d..c987bc8b7b2 100644 --- a/packages/types/src/signUp.ts +++ b/packages/types/src/signUp.ts @@ -117,6 +117,11 @@ export interface SignUpResource extends ClerkResource { authenticateWithCoinbaseWallet: (params?: SignUpAuthenticateWithWeb3Params) => Promise; authenticateWithOKXWallet: (params?: SignUpAuthenticateWithWeb3Params) => Promise; __internal_toSnapshot: () => SignUpJSONSnapshot; + + /** + * @internal + */ + __internal_future: SignUpFutureResource; } export interface SignUpFutureResource { @@ -127,7 +132,7 @@ export interface SignUpFutureResource { verifyEmailCode: (params: { code: string }) => Promise<{ error: unknown }>; }; password: (params: { emailAddress: string; password: string }) => Promise<{ error: unknown }>; - finalize: (params: { navigate?: SetActiveNavigate }) => Promise<{ error: unknown }>; + finalize: (params?: { navigate?: SetActiveNavigate }) => Promise<{ error: unknown }>; } export type SignUpStatus = 'missing_requirements' | 'complete' | 'abandoned'; From 6db7ec3a1a8632f9103d1dbaee6300d3783b30c9 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:32:20 -0500 Subject: [PATCH 2/3] fix(clerk-react): Throw error when calling State methods outside of the browser --- packages/react/src/stateProxy.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index 9c027aad4d4..f2d383e8386 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -1,6 +1,8 @@ import type { Errors, State } from '@clerk/types'; import type { IsomorphicClerk } from './isomorphicClerk'; +import { inBrowser } from '@clerk/shared'; +import { errorThrower } from './errors/errorThrower'; const defaultErrors = (): Errors => ({ fields: { @@ -91,6 +93,9 @@ export class StateProxy implements State { private gateMethod(getTarget: () => T, key: K) { type F = Extract unknown>; return (async (...args: Parameters): Promise> => { + if (!inBrowser()) { + return errorThrower.throw(`Attempted to call a method (${key}) that is not supported on the server.`); + } if (!this.isomorphicClerk.loaded) { await new Promise(resolve => this.isomorphicClerk.addOnLoaded(resolve)); } From 20a720cab88749281d52d77b941d991c83ee24be Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:49:11 -0500 Subject: [PATCH 3/3] chore(clerk-react): Lint --- packages/react/src/stateProxy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index f2d383e8386..febf248e7c6 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -1,8 +1,8 @@ +import { inBrowser } from '@clerk/shared/browser'; import type { Errors, State } from '@clerk/types'; -import type { IsomorphicClerk } from './isomorphicClerk'; -import { inBrowser } from '@clerk/shared'; import { errorThrower } from './errors/errorThrower'; +import type { IsomorphicClerk } from './isomorphicClerk'; const defaultErrors = (): Errors => ({ fields: {