diff --git a/.changeset/dull-cups-accept.md b/.changeset/dull-cups-accept.md new file mode 100644 index 00000000000..e9c91de2f7e --- /dev/null +++ b/.changeset/dull-cups-accept.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': patch +'@clerk/clerk-react': patch +'@clerk/types': patch +--- + +[Experimental] Signals diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index df3e12a2a25..55893738a3c 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,9 +1,9 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "620KB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "74KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "115.08KB" }, - { "path": "./dist/clerk.headless*.js", "maxSize": "55.2KB" }, + { "path": "./dist/clerk.js", "maxSize": "621KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "75KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" }, + { "path": "./dist/clerk.headless*.js", "maxSize": "57KB" }, { "path": "./dist/ui-common*.js", "maxSize": "113KB" }, { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "118KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 90612ac652f..b0e2b93c080 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -75,6 +75,7 @@ "@swc/helpers": "^0.5.17", "@zxcvbn-ts/core": "3.0.4", "@zxcvbn-ts/language-common": "3.0.4", + "alien-signals": "2.0.6", "browser-tabs-lock": "1.3.0", "copy-to-clipboard": "3.3.3", "core-js": "3.41.0", diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index c402d6f1aa3..84f6ce868d4 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -154,6 +154,7 @@ import { Waitlist, } from './resources/internal'; import { navigateToTask } from './sessionTasks'; +import { State } from './state'; import { warnings } from './warnings'; type SetActiveHook = (intent?: 'sign-out') => void | Promise; @@ -211,6 +212,7 @@ export class Clerk implements ClerkInterface { public user: UserResource | null | undefined; public __internal_country?: string | null; public telemetry: TelemetryCollector | undefined; + public readonly __internal_state: State = new State(); protected internal_last_error: ClerkAPIError | null = null; // converted to protected environment to support `updateEnvironment` type assertion diff --git a/packages/clerk-js/src/core/events.ts b/packages/clerk-js/src/core/events.ts index 55f1d8996ab..858aa6d10ee 100644 --- a/packages/clerk-js/src/core/events.ts +++ b/packages/clerk-js/src/core/events.ts @@ -1,20 +1,28 @@ import { createEventBus } from '@clerk/shared/eventBus'; import type { TokenResource } from '@clerk/types'; +import type { BaseResource } from './resources/Base'; + export const events = { TokenUpdate: 'token:update', UserSignOut: 'user:signOut', EnvironmentUpdate: 'environment:update', SessionTokenResolved: 'session:tokenResolved', + ResourceUpdate: 'resource:update', + ResourceError: 'resource:error', } as const; type TokenUpdatePayload = { token: TokenResource | null }; +export type ResourceUpdatePayload = { resource: BaseResource }; +export type ResourceErrorPayload = { resource: BaseResource; error: unknown }; type InternalEvents = { [events.TokenUpdate]: TokenUpdatePayload; [events.UserSignOut]: null; [events.EnvironmentUpdate]: null; [events.SessionTokenResolved]: null; + [events.ResourceUpdate]: ResourceUpdatePayload; + [events.ResourceError]: ResourceErrorPayload; }; export const eventBus = createEventBus(); diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 544922fcb4c..1585c731f79 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -16,6 +16,7 @@ import type { EmailCodeConfig, EmailLinkConfig, EnterpriseSSOConfig, + OAuthStrategy, PassKeyConfig, PasskeyFactor, PhoneCodeConfig, @@ -27,6 +28,7 @@ import type { SamlConfig, SignInCreateParams, SignInFirstFactor, + SignInFutureResource, SignInIdentifier, SignInJSON, SignInJSONSnapshot, @@ -66,6 +68,7 @@ import { clerkVerifyPasskeyCalledBeforeCreate, clerkVerifyWeb3WalletCalledBeforeCreate, } from '../errors'; +import { eventBus } from '../events'; import { BaseResource, UserData, Verification } from './internal'; export class SignIn extends BaseResource implements SignInResource { @@ -82,6 +85,21 @@ export class SignIn extends BaseResource implements SignInResource { createdSessionId: string | null = null; userData: UserData = new UserData(null); + /** + * @experimental This experimental API is subject to change. + * + * 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 Only used for internal purposes, and is not intended to be used directly. + * + * This property is used to provide access to underlying Client methods to `SignInFuture`, which wraps an instance + * of `SignIn`. + */ + __internal_basePost = this._basePost.bind(this); + constructor(data: SignInJSON | SignInJSONSnapshot | null = null) { super(); this.fromJSON(data); @@ -451,6 +469,8 @@ export class SignIn extends BaseResource implements SignInResource { this.createdSessionId = data.created_session_id; this.userData = new UserData(data.user_data); } + + eventBus.emit('resource:update', { resource: this }); return this; } @@ -470,3 +490,132 @@ export class SignIn extends BaseResource implements SignInResource { }; } } + +class SignInFuture implements SignInFutureResource { + emailCode = { + sendCode: this.sendEmailCode.bind(this), + verifyCode: this.verifyEmailCode.bind(this), + }; + + constructor(readonly resource: SignIn) {} + + get status() { + return this.resource.status; + } + + async create(params: { + identifier?: string; + strategy?: OAuthStrategy | 'saml' | 'enterprise_sso'; + redirectUrl?: string; + actionCompleteRedirectUrl?: string; + }): Promise<{ error: unknown }> { + eventBus.emit('resource:error', { resource: this.resource, error: null }); + try { + await this.resource.__internal_basePost({ + path: this.resource.pathRoot, + body: params, + }); + + return { error: null }; + } catch (err) { + eventBus.emit('resource:error', { resource: this.resource, error: err }); + return { error: err }; + } + } + + async password({ identifier, password }: { identifier: string; password: string }): Promise<{ error: unknown }> { + eventBus.emit('resource:error', { resource: this.resource, error: null }); + try { + await this.resource.__internal_basePost({ + path: this.resource.pathRoot, + body: { identifier, password }, + }); + } catch (err) { + eventBus.emit('resource:error', { resource: this.resource, error: err }); + return { error: err }; + } + + return { error: null }; + } + + async sendEmailCode({ email }: { email: string }): Promise<{ error: unknown }> { + eventBus.emit('resource:error', { resource: this.resource, error: null }); + try { + if (!this.resource.id) { + await this.create({ identifier: email }); + } + + const emailCodeFactor = this.resource.supportedFirstFactors?.find(f => f.strategy === 'email_code'); + + if (!emailCodeFactor) { + throw new Error('Email code factor not found'); + } + + const { emailAddressId } = emailCodeFactor; + await this.resource.__internal_basePost({ + body: { emailAddressId, strategy: 'email_code' }, + action: 'prepare_first_factor', + }); + } catch (err: unknown) { + eventBus.emit('resource:error', { resource: this.resource, error: err }); + return { error: err }; + } + + return { error: null }; + } + + async verifyEmailCode({ code }: { code: string }): Promise<{ error: unknown }> { + eventBus.emit('resource:error', { resource: this.resource, error: null }); + try { + await this.resource.__internal_basePost({ + body: { code, strategy: 'email_code' }, + action: 'attempt_first_factor', + }); + } catch (err: unknown) { + eventBus.emit('resource:error', { resource: this.resource, error: err }); + return { error: err }; + } + + return { error: null }; + } + + async sso({ + flow = 'auto', + strategy, + redirectUrl, + redirectUrlComplete, + }: { + flow?: 'auto' | 'modal'; + strategy: OAuthStrategy | 'saml' | 'enterprise_sso'; + redirectUrl: string; + redirectUrlComplete: string; + }): Promise<{ error: unknown }> { + eventBus.emit('resource:error', { resource: this.resource, error: null }); + try { + if (flow !== 'auto') { + throw new Error('modal flow is not supported yet'); + } + + const redirectUrlWithAuthToken = SignIn.clerk.buildUrlWithAuth(redirectUrl); + + if (!this.resource.id) { + await this.create({ + strategy, + redirectUrl: redirectUrlWithAuthToken, + actionCompleteRedirectUrl: redirectUrlComplete, + }); + } + + const { status, externalVerificationRedirectURL } = this.resource.firstFactorVerification; + + if (status === 'unverified' && externalVerificationRedirectURL) { + windowNavigate(externalVerificationRedirectURL); + } + } catch (err: unknown) { + eventBus.emit('resource:error', { resource: this.resource, error: err }); + return { error: err }; + } + + return { error: null }; + } +} diff --git a/packages/clerk-js/src/core/signals.ts b/packages/clerk-js/src/core/signals.ts new file mode 100644 index 00000000000..781b082822a --- /dev/null +++ b/packages/clerk-js/src/core/signals.ts @@ -0,0 +1,17 @@ +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 signInComputedSignal = computed(() => { + const signIn = signInSignal().resource; + const errors = signInErrorSignal().errors; + + if (!signIn) { + return { errors: null, signIn: null }; + } + + return { errors, signIn: signIn.__internal_future }; +}); diff --git a/packages/clerk-js/src/core/state.ts b/packages/clerk-js/src/core/state.ts new file mode 100644 index 00000000000..b18c07ca78b --- /dev/null +++ b/packages/clerk-js/src/core/state.ts @@ -0,0 +1,33 @@ +import type { State as StateInterface } from '@clerk/types'; +import { computed, effect } from 'alien-signals'; + +import { eventBus } from './events'; +import type { BaseResource } from './resources/Base'; +import { SignIn } from './resources/SignIn'; +import { signInComputedSignal, signInErrorSignal, signInSignal } from './signals'; + +export class State implements StateInterface { + signInResourceSignal = signInSignal; + signInErrorSignal = signInErrorSignal; + signInSignal = signInComputedSignal; + + __internal_effect = effect; + __internal_computed = computed; + + constructor() { + eventBus.on('resource:update', this.onResourceUpdated); + eventBus.on('resource:error', this.onResourceError); + } + + private onResourceError = (payload: { resource: BaseResource; error: unknown }) => { + if (payload.resource instanceof SignIn) { + this.signInErrorSignal({ errors: payload.error }); + } + }; + + private onResourceUpdated = (payload: { resource: BaseResource }) => { + if (payload.resource instanceof SignIn) { + this.signInResourceSignal({ resource: payload.resource }); + } + }; +} diff --git a/packages/react/src/experimental.ts b/packages/react/src/experimental.ts index a0378d0fb80..73b6ff81985 100644 --- a/packages/react/src/experimental.ts +++ b/packages/react/src/experimental.ts @@ -1,6 +1,7 @@ export { CheckoutButton } from './components/CheckoutButton'; export { PlanDetailsButton } from './components/PlanDetailsButton'; export { SubscriptionDetailsButton } from './components/SubscriptionDetailsButton'; +export { useSignInSignal } from './hooks/useClerkSignal'; export type { __experimental_CheckoutButtonProps as CheckoutButtonProps, diff --git a/packages/react/src/hooks/useClerkSignal.ts b/packages/react/src/hooks/useClerkSignal.ts new file mode 100644 index 00000000000..37e9851ec6b --- /dev/null +++ b/packages/react/src/hooks/useClerkSignal.ts @@ -0,0 +1,52 @@ +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 { + useAssertWrappedByClerkProvider('useSignInSignal'); + + const clerk = useIsomorphicClerkContext(); + + const subscribe = useCallback( + (callback: () => void) => { + if (!clerk.loaded || !clerk.__internal_state) { + 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(); + break; + default: + throw new Error(`Unknown signal: ${signal}`); + } + callback(); + }); + }, + [clerk, clerk.loaded, clerk.__internal_state], + ); + const getSnapshot = useCallback(() => { + if (!clerk.__internal_state) { + return null; + } + + switch (signal) { + case 'signIn': + return clerk.__internal_state.signInSignal(); + default: + throw new Error(`Unknown signal: ${signal}`); + } + }, [clerk.__internal_state]); + + const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + return value; +} + +export function useSignInSignal() { + return useClerkSignal('signIn'); +} diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 91b52d821a6..0eba21ccd54 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -44,6 +44,7 @@ import type { SignUpProps, SignUpRedirectOptions, SignUpResource, + State, TaskChooseOrganizationProps, UnsubscribeCallback, UserButtonProps, @@ -714,6 +715,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return this.clerkjs?.billing; } + get __internal_state(): State | undefined { + return this.clerkjs?.__internal_state; + } + get apiKeys(): APIKeysNamespace | undefined { return this.clerkjs?.apiKeys; } diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 647e4d64e7a..2d55fd4341b 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -54,6 +54,7 @@ import type { SessionVerificationLevel } from './sessionVerification'; import type { SignInResource } from './signIn'; import type { SignUpResource } from './signUp'; import type { ClientJSONSnapshot, EnvironmentJSONSnapshot } from './snapshots'; +import type { State } from './state'; import type { Web3Strategy } from './strategies'; import type { TelemetryCollector } from './telemetry'; import type { UserResource } from './user'; @@ -227,6 +228,14 @@ export interface Clerk { /** Current User. */ user: UserResource | null | undefined; + /** + * @experimental This experimental API is subject to change. + * + * 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; + /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * @see https://clerk.com/docs/billing/overview diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 854394dbeba..0e1f312dc2e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -49,6 +49,7 @@ export * from './sessionVerification'; export * from './signIn'; export * from './signUp'; export * from './ssr'; +export * from './state'; export * from './strategies'; export * from './theme'; export * from './token'; diff --git a/packages/types/src/signIn.ts b/packages/types/src/signIn.ts index 0f8ccdbde66..43d7a5fc5e4 100644 --- a/packages/types/src/signIn.ts +++ b/packages/types/src/signIn.ts @@ -125,6 +125,22 @@ export interface SignInResource extends ClerkResource { __internal_toSnapshot: () => SignInJSONSnapshot; } +export interface SignInFutureResource { + status: SignInStatus | null; + create: (params: { identifier: string }) => Promise<{ error: unknown }>; + password: (params: { identifier: string; password: string }) => Promise<{ error: unknown }>; + emailCode: { + sendCode: (params: { email: string }) => Promise<{ error: unknown }>; + verifyCode: (params: { code: string }) => Promise<{ error: unknown }>; + }; + sso: (params: { + flow?: 'auto' | 'modal'; + strategy: OAuthStrategy | 'saml' | 'enterprise_sso'; + redirectUrl: string; + redirectUrlComplete: string; + }) => Promise<{ error: unknown }>; +} + export type SignInStatus = | 'needs_identifier' | 'needs_first_factor' diff --git a/packages/types/src/state.ts b/packages/types/src/state.ts new file mode 100644 index 00000000000..8f91dde4412 --- /dev/null +++ b/packages/types/src/state.ts @@ -0,0 +1,33 @@ +import type { SignInFutureResource } from './signIn'; + +export interface State { + /** + * A Signal that updates when the underlying `SignIn` resource changes, including errors. + */ + signInSignal: { + (): { + errors: unknown; + signIn: SignInFutureResource | null; + }; + (value: { errors: unknown; signIn: SignInFutureResource | null }): void; + }; + + /** + * @experimental This experimental API is subject to change. + * + * An alias for `effect()` from `alien-signals`, which can be used to subscribe to changes from Signals. + * + * @see https://github.com/stackblitz/alien-signals#usage + */ + __internal_effect: (callback: () => void) => () => void; + + /** + * @experimental This experimental API is subject to change. + * + * An alias for `computed()` from `alien-signals`, which can be used to create a computed Signal that updates when + * its dependencies change. + * + * @see https://github.com/stackblitz/alien-signals#usage + */ + __internal_computed: (getter: (previousValue?: T) => T) => () => T; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8940f9d33ef..9ac5ae0c977 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -473,6 +473,9 @@ importers: '@zxcvbn-ts/language-common': specifier: 3.0.4 version: 3.0.4 + alien-signals: + specifier: 2.0.6 + version: 2.0.6 browser-tabs-lock: specifier: 1.3.0 version: 1.3.0 @@ -2902,7 +2905,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} @@ -5879,6 +5882,9 @@ packages: alien-signals@1.0.9: resolution: {integrity: sha512-2dQYgGZHrW4pOYv0BWiw4cH/ElhwmLnQDcj/fdnRRF2OO3YBqgJXSleI1EbbXdQsuC5oCvr6+VKAOEElsmcx4Q==} + alien-signals@2.0.6: + resolution: {integrity: sha512-P3TxJSe31bUHBiblg59oU1PpaWPtmxF9GhJ/cB7OkgJ0qN/ifFSKUI25/v8ZhsT+lIG6ac8DpTOplXxORX6F3Q==} + anser@1.4.10: resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} @@ -12157,7 +12163,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qrcode-terminal@0.11.0: @@ -13319,7 +13324,7 @@ packages: superagent@8.1.2: resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net + deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net superjson@1.13.3: resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==} @@ -21134,6 +21139,8 @@ snapshots: alien-signals@1.0.9: {} + alien-signals@2.0.6: {} + anser@1.4.10: {} ansi-align@3.0.1: