Skip to content

Commit c16a7a5

Browse files
authored
feat(clerk-js,clerk-react,types): Update signal hooks to always return values (#6605)
1 parent 35a8cae commit c16a7a5

File tree

9 files changed

+160
-23
lines changed

9 files changed

+160
-23
lines changed

.changeset/rotten-falcons-cover.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/clerk-react': minor
4+
'@clerk/types': minor
5+
---
6+
7+
[Experimental] Signals `isLoaded` removal

packages/clerk-js/src/core/resources/SignIn.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export class SignIn extends BaseResource implements SignInResource {
9191
*
9292
* An instance of `SignInFuture`, which has a different API than `SignIn`, intended to be used in custom flows.
9393
*/
94-
__internal_future: SignInFuture | null = new SignInFuture(this);
94+
__internal_future: SignInFuture = new SignInFuture(this);
9595

9696
/**
9797
* @internal Only used for internal purposes, and is not intended to be used directly.
@@ -638,7 +638,7 @@ class SignInFuture implements SignInFutureResource {
638638
});
639639
}
640640

641-
async finalize({ navigate }: { navigate?: SetActiveNavigate }): Promise<{ error: unknown }> {
641+
async finalize({ navigate }: { navigate?: SetActiveNavigate } = {}): Promise<{ error: unknown }> {
642642
return runAsyncResourceTask(this.resource, async () => {
643643
if (!this.resource.createdSessionId) {
644644
throw new Error('Cannot finalize sign-in without a created session.');

packages/clerk-js/src/core/resources/SignUp.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export class SignUp extends BaseResource implements SignUpResource {
8787
*
8888
* An instance of `SignUpFuture`, which has a different API than `SignUp`, intended to be used in custom flows.
8989
*/
90-
__internal_future: SignUpFuture | null = new SignUpFuture(this);
90+
__internal_future: SignUpFuture = new SignUpFuture(this);
9191

9292
/**
9393
* @internal Only used for internal purposes, and is not intended to be used directly.
@@ -539,7 +539,7 @@ class SignUpFuture implements SignUpFutureResource {
539539
});
540540
}
541541

542-
async finalize({ navigate }: { navigate?: SetActiveNavigate }): Promise<{ error: unknown }> {
542+
async finalize({ navigate }: { navigate?: SetActiveNavigate } = {}): Promise<{ error: unknown }> {
543543
return runAsyncResourceTask(this.resource, async () => {
544544
if (!this.resource.createdSessionId) {
545545
throw new Error('Cannot finalize sign-up without a created session.');

packages/react/src/hooks/useClerkSignal.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,36 @@ import { useCallback, useSyncExternalStore } from 'react';
44
import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext';
55
import { useAssertWrappedByClerkProvider } from './useAssertWrappedByClerkProvider';
66

7-
function useClerkSignal(signal: 'signIn'): ReturnType<SignInSignal> | null;
8-
function useClerkSignal(signal: 'signUp'): ReturnType<SignUpSignal> | null;
9-
function useClerkSignal(signal: 'signIn' | 'signUp'): ReturnType<SignInSignal> | ReturnType<SignUpSignal> | null {
7+
// These types are used to remove the `null` value from the underlying resource. This is safe because IsomorphicClerk
8+
// always returns a valid resource, even before Clerk is loaded, and if Clerk is loaded, the resource is guaranteed to
9+
// be non-null
10+
type NonNullSignInSignal = Omit<ReturnType<SignInSignal>, 'signIn'> & {
11+
signIn: NonNullable<ReturnType<SignInSignal>['signIn']>;
12+
};
13+
type NonNullSignUpSignal = Omit<ReturnType<SignUpSignal>, 'signUp'> & {
14+
signUp: NonNullable<ReturnType<SignUpSignal>['signUp']>;
15+
};
16+
17+
function useClerkSignal(signal: 'signIn'): NonNullSignInSignal;
18+
function useClerkSignal(signal: 'signUp'): NonNullSignUpSignal;
19+
function useClerkSignal(signal: 'signIn' | 'signUp'): NonNullSignInSignal | NonNullSignUpSignal {
1020
useAssertWrappedByClerkProvider('useClerkSignal');
1121

1222
const clerk = useIsomorphicClerkContext();
1323

1424
const subscribe = useCallback(
1525
(callback: () => void) => {
16-
if (!clerk.loaded || !clerk.__internal_state) {
26+
if (!clerk.loaded) {
1727
return () => {};
1828
}
1929

2030
return clerk.__internal_state.__internal_effect(() => {
2131
switch (signal) {
2232
case 'signIn':
23-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know that the state is defined
24-
clerk.__internal_state!.signInSignal();
33+
clerk.__internal_state.signInSignal();
2534
break;
2635
case 'signUp':
27-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know that the state is defined
28-
clerk.__internal_state!.signUpSignal();
36+
clerk.__internal_state.signUpSignal();
2937
break;
3038
default:
3139
throw new Error(`Unknown signal: ${signal}`);
@@ -36,15 +44,11 @@ function useClerkSignal(signal: 'signIn' | 'signUp'): ReturnType<SignInSignal> |
3644
[clerk, clerk.loaded, clerk.__internal_state],
3745
);
3846
const getSnapshot = useCallback(() => {
39-
if (!clerk.__internal_state) {
40-
return null;
41-
}
42-
4347
switch (signal) {
4448
case 'signIn':
45-
return clerk.__internal_state.signInSignal();
49+
return clerk.__internal_state.signInSignal() as NonNullSignInSignal;
4650
case 'signUp':
47-
return clerk.__internal_state.signUpSignal();
51+
return clerk.__internal_state.signUpSignal() as NonNullSignUpSignal;
4852
default:
4953
throw new Error(`Unknown signal: ${signal}`);
5054
}

packages/react/src/isomorphicClerk.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import type {
5656

5757
import { errorThrower } from './errors/errorThrower';
5858
import { unsupportedNonBrowserDomainOrProxyUrlFunction } from './errors/messages';
59+
import { StateProxy } from './stateProxy';
5960
import type {
6061
BrowserClerk,
6162
BrowserClerkConstructor,
@@ -157,6 +158,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
157158
#proxyUrl: DomainOrProxyUrl['proxyUrl'];
158159
#publishableKey: string;
159160
#eventBus = createClerkEventBus();
161+
#stateProxy: StateProxy;
160162

161163
get publishableKey(): string {
162164
return this.#publishableKey;
@@ -249,6 +251,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
249251
this.options = options;
250252
this.Clerk = Clerk;
251253
this.mode = inBrowser() ? 'browser' : 'server';
254+
this.#stateProxy = new StateProxy(this);
252255

253256
if (!this.options.sdkMetadata) {
254257
this.options.sdkMetadata = SDK_METADATA;
@@ -722,8 +725,8 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
722725
return this.clerkjs?.billing;
723726
}
724727

725-
get __internal_state(): State | undefined {
726-
return this.clerkjs?.__internal_state;
728+
get __internal_state(): State {
729+
return this.loaded && this.clerkjs ? this.clerkjs.__internal_state : this.#stateProxy;
727730
}
728731

729732
get apiKeys(): APIKeysNamespace | undefined {

packages/react/src/stateProxy.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { inBrowser } from '@clerk/shared/browser';
2+
import type { Errors, State } from '@clerk/types';
3+
4+
import { errorThrower } from './errors/errorThrower';
5+
import type { IsomorphicClerk } from './isomorphicClerk';
6+
7+
const defaultErrors = (): Errors => ({
8+
fields: {
9+
firstName: null,
10+
lastName: null,
11+
emailAddress: null,
12+
identifier: null,
13+
phoneNumber: null,
14+
password: null,
15+
username: null,
16+
code: null,
17+
captcha: null,
18+
legalAccepted: null,
19+
},
20+
raw: [],
21+
global: [],
22+
});
23+
24+
export class StateProxy implements State {
25+
constructor(private isomorphicClerk: IsomorphicClerk) {}
26+
27+
private readonly signInSignalProxy = this.buildSignInProxy();
28+
private readonly signUpSignalProxy = this.buildSignUpProxy();
29+
30+
signInSignal() {
31+
return this.signInSignalProxy;
32+
}
33+
signUpSignal() {
34+
return this.signUpSignalProxy;
35+
}
36+
37+
private buildSignInProxy() {
38+
const target = () => this.client.signIn.__internal_future;
39+
40+
return {
41+
errors: defaultErrors(),
42+
fetchStatus: 'idle' as const,
43+
signIn: {
44+
status: 'needs_identifier' as const,
45+
availableStrategies: [],
46+
47+
create: this.gateMethod(target, 'create'),
48+
password: this.gateMethod(target, 'password'),
49+
sso: this.gateMethod(target, 'sso'),
50+
finalize: this.gateMethod(target, 'finalize'),
51+
52+
emailCode: this.wrapMethods(() => target().emailCode, ['sendCode', 'verifyCode'] as const),
53+
resetPasswordEmailCode: this.wrapMethods(() => target().resetPasswordEmailCode, [
54+
'sendCode',
55+
'verifyCode',
56+
'submitPassword',
57+
] as const),
58+
},
59+
};
60+
}
61+
62+
private buildSignUpProxy() {
63+
const target = () => this.client.signUp.__internal_future;
64+
65+
return {
66+
errors: defaultErrors(),
67+
fetchStatus: 'idle' as const,
68+
signUp: {
69+
status: 'missing_requirements' as const,
70+
unverifiedFields: [],
71+
72+
password: this.gateMethod(target, 'password'),
73+
finalize: this.gateMethod(target, 'finalize'),
74+
75+
verifications: this.wrapMethods(() => target().verifications, ['sendEmailCode', 'verifyEmailCode'] as const),
76+
},
77+
};
78+
}
79+
80+
__internal_effect(_: () => void): () => void {
81+
throw new Error('__internal_effect called before Clerk is loaded');
82+
}
83+
__internal_computed<T>(_: (prev?: T) => T): () => T {
84+
throw new Error('__internal_computed called before Clerk is loaded');
85+
}
86+
87+
private get client() {
88+
const c = this.isomorphicClerk.client;
89+
if (!c) throw new Error('Clerk client not ready');
90+
return c;
91+
}
92+
93+
private gateMethod<T extends object, K extends keyof T & string>(getTarget: () => T, key: K) {
94+
type F = Extract<T[K], (...args: unknown[]) => unknown>;
95+
return (async (...args: Parameters<F>): Promise<ReturnType<F>> => {
96+
if (!inBrowser()) {
97+
return errorThrower.throw(`Attempted to call a method (${key}) that is not supported on the server.`);
98+
}
99+
if (!this.isomorphicClerk.loaded) {
100+
await new Promise<void>(resolve => this.isomorphicClerk.addOnLoaded(resolve));
101+
}
102+
const t = getTarget();
103+
return (t[key] as (...args: Parameters<F>) => ReturnType<F>).apply(t, args);
104+
}) as F;
105+
}
106+
107+
private wrapMethods<T extends object, K extends readonly (keyof T & string)[]>(
108+
getTarget: () => T,
109+
keys: K,
110+
): Pick<T, K[number]> {
111+
return Object.fromEntries(keys.map(k => [k, this.gateMethod(getTarget, k)])) as Pick<T, K[number]>;
112+
}
113+
}

packages/types/src/clerk.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ export interface Clerk {
235235
* Entrypoint for Clerk's Signal API containing resource signals along with accessible versions of `computed()` and
236236
* `effect()` that can be used to subscribe to changes from Signals.
237237
*/
238-
__internal_state: State | undefined;
238+
__internal_state: State;
239239

240240
/**
241241
* @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change.

packages/types/src/signIn.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ export interface SignInResource extends ClerkResource {
124124
* @internal
125125
*/
126126
__internal_toSnapshot: () => SignInJSONSnapshot;
127+
128+
/**
129+
* @internal
130+
*/
131+
__internal_future: SignInFutureResource;
127132
}
128133

129134
export interface SignInFutureResource {
@@ -151,7 +156,7 @@ export interface SignInFutureResource {
151156
redirectUrl: string;
152157
redirectUrlComplete: string;
153158
}) => Promise<{ error: unknown }>;
154-
finalize: (params: { navigate?: SetActiveNavigate }) => Promise<{ error: unknown }>;
159+
finalize: (params?: { navigate?: SetActiveNavigate }) => Promise<{ error: unknown }>;
155160
}
156161

157162
export type SignInStatus =

packages/types/src/signUp.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ export interface SignUpResource extends ClerkResource {
117117
authenticateWithCoinbaseWallet: (params?: SignUpAuthenticateWithWeb3Params) => Promise<SignUpResource>;
118118
authenticateWithOKXWallet: (params?: SignUpAuthenticateWithWeb3Params) => Promise<SignUpResource>;
119119
__internal_toSnapshot: () => SignUpJSONSnapshot;
120+
121+
/**
122+
* @internal
123+
*/
124+
__internal_future: SignUpFutureResource;
120125
}
121126

122127
export interface SignUpFutureResource {
@@ -127,7 +132,7 @@ export interface SignUpFutureResource {
127132
verifyEmailCode: (params: { code: string }) => Promise<{ error: unknown }>;
128133
};
129134
password: (params: { emailAddress: string; password: string }) => Promise<{ error: unknown }>;
130-
finalize: (params: { navigate?: SetActiveNavigate }) => Promise<{ error: unknown }>;
135+
finalize: (params?: { navigate?: SetActiveNavigate }) => Promise<{ error: unknown }>;
131136
}
132137

133138
export type SignUpStatus = 'missing_requirements' | 'complete' | 'abandoned';

0 commit comments

Comments
 (0)