Skip to content

Commit 4edef81

Browse files
authored
feat(clerk-js,clerk-react,types): Introduce state signals (#6450)
1 parent e82f177 commit 4edef81

File tree

16 files changed

+348
-7
lines changed

16 files changed

+348
-7
lines changed

.changeset/dull-cups-accept.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/clerk-react': patch
4+
'@clerk/types': patch
5+
---
6+
7+
[Experimental] Signals

packages/clerk-js/bundlewatch.config.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"files": [
3-
{ "path": "./dist/clerk.js", "maxSize": "620KB" },
4-
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
5-
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "115.08KB" },
6-
{ "path": "./dist/clerk.headless*.js", "maxSize": "55.2KB" },
3+
{ "path": "./dist/clerk.js", "maxSize": "621KB" },
4+
{ "path": "./dist/clerk.browser.js", "maxSize": "75KB" },
5+
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" },
6+
{ "path": "./dist/clerk.headless*.js", "maxSize": "57KB" },
77
{ "path": "./dist/ui-common*.js", "maxSize": "113KB" },
88
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "118KB" },
99
{ "path": "./dist/vendors*.js", "maxSize": "40.2KB" },

packages/clerk-js/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"@swc/helpers": "^0.5.17",
7676
"@zxcvbn-ts/core": "3.0.4",
7777
"@zxcvbn-ts/language-common": "3.0.4",
78+
"alien-signals": "2.0.6",
7879
"browser-tabs-lock": "1.3.0",
7980
"copy-to-clipboard": "3.3.3",
8081
"core-js": "3.41.0",

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ import {
154154
Waitlist,
155155
} from './resources/internal';
156156
import { navigateToTask } from './sessionTasks';
157+
import { State } from './state';
157158
import { warnings } from './warnings';
158159

159160
type SetActiveHook = (intent?: 'sign-out') => void | Promise<void>;
@@ -211,6 +212,7 @@ export class Clerk implements ClerkInterface {
211212
public user: UserResource | null | undefined;
212213
public __internal_country?: string | null;
213214
public telemetry: TelemetryCollector | undefined;
215+
public readonly __internal_state: State = new State();
214216

215217
protected internal_last_error: ClerkAPIError | null = null;
216218
// converted to protected environment to support `updateEnvironment` type assertion

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
import { createEventBus } from '@clerk/shared/eventBus';
22
import type { TokenResource } from '@clerk/types';
33

4+
import type { BaseResource } from './resources/Base';
5+
46
export const events = {
57
TokenUpdate: 'token:update',
68
UserSignOut: 'user:signOut',
79
EnvironmentUpdate: 'environment:update',
810
SessionTokenResolved: 'session:tokenResolved',
11+
ResourceUpdate: 'resource:update',
12+
ResourceError: 'resource:error',
913
} as const;
1014

1115
type TokenUpdatePayload = { token: TokenResource | null };
16+
export type ResourceUpdatePayload = { resource: BaseResource };
17+
export type ResourceErrorPayload = { resource: BaseResource; error: unknown };
1218

1319
type InternalEvents = {
1420
[events.TokenUpdate]: TokenUpdatePayload;
1521
[events.UserSignOut]: null;
1622
[events.EnvironmentUpdate]: null;
1723
[events.SessionTokenResolved]: null;
24+
[events.ResourceUpdate]: ResourceUpdatePayload;
25+
[events.ResourceError]: ResourceErrorPayload;
1826
};
1927

2028
export const eventBus = createEventBus<InternalEvents>();

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

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
EmailCodeConfig,
1717
EmailLinkConfig,
1818
EnterpriseSSOConfig,
19+
OAuthStrategy,
1920
PassKeyConfig,
2021
PasskeyFactor,
2122
PhoneCodeConfig,
@@ -27,6 +28,7 @@ import type {
2728
SamlConfig,
2829
SignInCreateParams,
2930
SignInFirstFactor,
31+
SignInFutureResource,
3032
SignInIdentifier,
3133
SignInJSON,
3234
SignInJSONSnapshot,
@@ -66,6 +68,7 @@ import {
6668
clerkVerifyPasskeyCalledBeforeCreate,
6769
clerkVerifyWeb3WalletCalledBeforeCreate,
6870
} from '../errors';
71+
import { eventBus } from '../events';
6972
import { BaseResource, UserData, Verification } from './internal';
7073

7174
export class SignIn extends BaseResource implements SignInResource {
@@ -82,6 +85,21 @@ export class SignIn extends BaseResource implements SignInResource {
8285
createdSessionId: string | null = null;
8386
userData: UserData = new UserData(null);
8487

88+
/**
89+
* @experimental This experimental API is subject to change.
90+
*
91+
* An instance of `SignInFuture`, which has a different API than `SignIn`, intended to be used in custom flows.
92+
*/
93+
__internal_future: SignInFuture | null = new SignInFuture(this);
94+
95+
/**
96+
* @internal Only used for internal purposes, and is not intended to be used directly.
97+
*
98+
* This property is used to provide access to underlying Client methods to `SignInFuture`, which wraps an instance
99+
* of `SignIn`.
100+
*/
101+
__internal_basePost = this._basePost.bind(this);
102+
85103
constructor(data: SignInJSON | SignInJSONSnapshot | null = null) {
86104
super();
87105
this.fromJSON(data);
@@ -451,6 +469,8 @@ export class SignIn extends BaseResource implements SignInResource {
451469
this.createdSessionId = data.created_session_id;
452470
this.userData = new UserData(data.user_data);
453471
}
472+
473+
eventBus.emit('resource:update', { resource: this });
454474
return this;
455475
}
456476

@@ -470,3 +490,132 @@ export class SignIn extends BaseResource implements SignInResource {
470490
};
471491
}
472492
}
493+
494+
class SignInFuture implements SignInFutureResource {
495+
emailCode = {
496+
sendCode: this.sendEmailCode.bind(this),
497+
verifyCode: this.verifyEmailCode.bind(this),
498+
};
499+
500+
constructor(readonly resource: SignIn) {}
501+
502+
get status() {
503+
return this.resource.status;
504+
}
505+
506+
async create(params: {
507+
identifier?: string;
508+
strategy?: OAuthStrategy | 'saml' | 'enterprise_sso';
509+
redirectUrl?: string;
510+
actionCompleteRedirectUrl?: string;
511+
}): Promise<{ error: unknown }> {
512+
eventBus.emit('resource:error', { resource: this.resource, error: null });
513+
try {
514+
await this.resource.__internal_basePost({
515+
path: this.resource.pathRoot,
516+
body: params,
517+
});
518+
519+
return { error: null };
520+
} catch (err) {
521+
eventBus.emit('resource:error', { resource: this.resource, error: err });
522+
return { error: err };
523+
}
524+
}
525+
526+
async password({ identifier, password }: { identifier: string; password: string }): Promise<{ error: unknown }> {
527+
eventBus.emit('resource:error', { resource: this.resource, error: null });
528+
try {
529+
await this.resource.__internal_basePost({
530+
path: this.resource.pathRoot,
531+
body: { identifier, password },
532+
});
533+
} catch (err) {
534+
eventBus.emit('resource:error', { resource: this.resource, error: err });
535+
return { error: err };
536+
}
537+
538+
return { error: null };
539+
}
540+
541+
async sendEmailCode({ email }: { email: string }): Promise<{ error: unknown }> {
542+
eventBus.emit('resource:error', { resource: this.resource, error: null });
543+
try {
544+
if (!this.resource.id) {
545+
await this.create({ identifier: email });
546+
}
547+
548+
const emailCodeFactor = this.resource.supportedFirstFactors?.find(f => f.strategy === 'email_code');
549+
550+
if (!emailCodeFactor) {
551+
throw new Error('Email code factor not found');
552+
}
553+
554+
const { emailAddressId } = emailCodeFactor;
555+
await this.resource.__internal_basePost({
556+
body: { emailAddressId, strategy: 'email_code' },
557+
action: 'prepare_first_factor',
558+
});
559+
} catch (err: unknown) {
560+
eventBus.emit('resource:error', { resource: this.resource, error: err });
561+
return { error: err };
562+
}
563+
564+
return { error: null };
565+
}
566+
567+
async verifyEmailCode({ code }: { code: string }): Promise<{ error: unknown }> {
568+
eventBus.emit('resource:error', { resource: this.resource, error: null });
569+
try {
570+
await this.resource.__internal_basePost({
571+
body: { code, strategy: 'email_code' },
572+
action: 'attempt_first_factor',
573+
});
574+
} catch (err: unknown) {
575+
eventBus.emit('resource:error', { resource: this.resource, error: err });
576+
return { error: err };
577+
}
578+
579+
return { error: null };
580+
}
581+
582+
async sso({
583+
flow = 'auto',
584+
strategy,
585+
redirectUrl,
586+
redirectUrlComplete,
587+
}: {
588+
flow?: 'auto' | 'modal';
589+
strategy: OAuthStrategy | 'saml' | 'enterprise_sso';
590+
redirectUrl: string;
591+
redirectUrlComplete: string;
592+
}): Promise<{ error: unknown }> {
593+
eventBus.emit('resource:error', { resource: this.resource, error: null });
594+
try {
595+
if (flow !== 'auto') {
596+
throw new Error('modal flow is not supported yet');
597+
}
598+
599+
const redirectUrlWithAuthToken = SignIn.clerk.buildUrlWithAuth(redirectUrl);
600+
601+
if (!this.resource.id) {
602+
await this.create({
603+
strategy,
604+
redirectUrl: redirectUrlWithAuthToken,
605+
actionCompleteRedirectUrl: redirectUrlComplete,
606+
});
607+
}
608+
609+
const { status, externalVerificationRedirectURL } = this.resource.firstFactorVerification;
610+
611+
if (status === 'unverified' && externalVerificationRedirectURL) {
612+
windowNavigate(externalVerificationRedirectURL);
613+
}
614+
} catch (err: unknown) {
615+
eventBus.emit('resource:error', { resource: this.resource, error: err });
616+
return { error: err };
617+
}
618+
619+
return { error: null };
620+
}
621+
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { computed, signal } from 'alien-signals';
2+
3+
import type { SignIn } from './resources/SignIn';
4+
5+
export const signInSignal = signal<{ resource: SignIn | null }>({ resource: null });
6+
export const signInErrorSignal = signal<{ errors: unknown }>({ errors: null });
7+
8+
export const signInComputedSignal = computed(() => {
9+
const signIn = signInSignal().resource;
10+
const errors = signInErrorSignal().errors;
11+
12+
if (!signIn) {
13+
return { errors: null, signIn: null };
14+
}
15+
16+
return { errors, signIn: signIn.__internal_future };
17+
});

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { State as StateInterface } from '@clerk/types';
2+
import { computed, effect } from 'alien-signals';
3+
4+
import { eventBus } from './events';
5+
import type { BaseResource } from './resources/Base';
6+
import { SignIn } from './resources/SignIn';
7+
import { signInComputedSignal, signInErrorSignal, signInSignal } from './signals';
8+
9+
export class State implements StateInterface {
10+
signInResourceSignal = signInSignal;
11+
signInErrorSignal = signInErrorSignal;
12+
signInSignal = signInComputedSignal;
13+
14+
__internal_effect = effect;
15+
__internal_computed = computed;
16+
17+
constructor() {
18+
eventBus.on('resource:update', this.onResourceUpdated);
19+
eventBus.on('resource:error', this.onResourceError);
20+
}
21+
22+
private onResourceError = (payload: { resource: BaseResource; error: unknown }) => {
23+
if (payload.resource instanceof SignIn) {
24+
this.signInErrorSignal({ errors: payload.error });
25+
}
26+
};
27+
28+
private onResourceUpdated = (payload: { resource: BaseResource }) => {
29+
if (payload.resource instanceof SignIn) {
30+
this.signInResourceSignal({ resource: payload.resource });
31+
}
32+
};
33+
}

packages/react/src/experimental.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { CheckoutButton } from './components/CheckoutButton';
22
export { PlanDetailsButton } from './components/PlanDetailsButton';
33
export { SubscriptionDetailsButton } from './components/SubscriptionDetailsButton';
4+
export { useSignInSignal } from './hooks/useClerkSignal';
45

56
export type {
67
__experimental_CheckoutButtonProps as CheckoutButtonProps,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { SignInFutureResource } from '@clerk/types';
2+
import { useCallback, useSyncExternalStore } from 'react';
3+
4+
import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext';
5+
import { useAssertWrappedByClerkProvider } from './useAssertWrappedByClerkProvider';
6+
7+
function useClerkSignal(signal: 'signIn'): { errors: unknown; signIn: SignInFutureResource | null } | null {
8+
useAssertWrappedByClerkProvider('useSignInSignal');
9+
10+
const clerk = useIsomorphicClerkContext();
11+
12+
const subscribe = useCallback(
13+
(callback: () => void) => {
14+
if (!clerk.loaded || !clerk.__internal_state) {
15+
return () => {};
16+
}
17+
18+
return clerk.__internal_state.__internal_effect(() => {
19+
switch (signal) {
20+
case 'signIn':
21+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know that the state is defined
22+
clerk.__internal_state!.signInSignal();
23+
break;
24+
default:
25+
throw new Error(`Unknown signal: ${signal}`);
26+
}
27+
callback();
28+
});
29+
},
30+
[clerk, clerk.loaded, clerk.__internal_state],
31+
);
32+
const getSnapshot = useCallback(() => {
33+
if (!clerk.__internal_state) {
34+
return null;
35+
}
36+
37+
switch (signal) {
38+
case 'signIn':
39+
return clerk.__internal_state.signInSignal();
40+
default:
41+
throw new Error(`Unknown signal: ${signal}`);
42+
}
43+
}, [clerk.__internal_state]);
44+
45+
const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
46+
47+
return value;
48+
}
49+
50+
export function useSignInSignal() {
51+
return useClerkSignal('signIn');
52+
}

0 commit comments

Comments
 (0)