diff --git a/.changeset/twenty-eggs-post.md b/.changeset/twenty-eggs-post.md new file mode 100644 index 00000000000..5fa5f7323ed --- /dev/null +++ b/.changeset/twenty-eggs-post.md @@ -0,0 +1,8 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +Introducing granular Clerk loading status diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 1cbfaca43fe..cc8c2aa84c8 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "581.5kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "79.30kB" }, + { "path": "./dist/clerk.js", "maxSize": "582kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "80kB" }, { "path": "./dist/clerk.headless.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "96KB" }, { "path": "./dist/vendors*.js", "maxSize": "30KB" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 72d6ef7ddcc..45334db5704 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1,6 +1,7 @@ import { inBrowser as inClientSide, isValidBrowserOnline } from '@clerk/shared/browser'; import { deprecated } from '@clerk/shared/deprecated'; import { ClerkRuntimeError, EmailLinkErrorCodeStatus, is4xxError, isClerkAPIResponseError } from '@clerk/shared/error'; +import { EventEmitter } from '@clerk/shared/event-emitter'; import { parsePublishableKey } from '@clerk/shared/keys'; import { LocalStorageBroadcastChannel } from '@clerk/shared/localStorageBroadcastChannel'; import { logger } from '@clerk/shared/logger'; @@ -187,6 +188,7 @@ export class Clerk implements ClerkInterface { protected internal_last_error: ClerkAPIError | null = null; // converted to protected environment to support `updateEnvironment` type assertion protected environment?: EnvironmentResource | null; + private eventEmitter = new EventEmitter(); #publishableKey = ''; #domain: DomainOrProxyUrl['domain']; @@ -198,7 +200,7 @@ export class Clerk implements ClerkInterface { //@ts-expect-error with being undefined even though it's not possible - related to issue with ts and error thrower #fapiClient: FapiClient; #instanceType?: InstanceType; - #loaded = false; + #status: ClerkInterface['status'] = 'uninitialized'; #listeners: Array<(emission: Resources) => void> = []; #navigationListeners: Array<() => void> = []; @@ -246,7 +248,22 @@ export class Clerk implements ClerkInterface { } get loaded(): boolean { - return this.#loaded; + return this.status === 'ready'; + } + + get status() { + return this.#status; + } + + set status(status: ClerkInterface['status']) { + // console.log(`status: ${this.#status} -> ${status}`); + if (this.#status === 'ready') { + throw new Error('Clerk status cannot be changed once the instance has been loaded.'); + } + if (status === 'ready') { + this.eventEmitter.emit('ready'); + } + this.#status = status; } get isSatellite(): boolean { @@ -355,41 +372,51 @@ export class Clerk implements ClerkInterface { public getFapiClient = (): FapiClient => this.#fapiClient; - public load = async (options?: ClerkOptions): Promise => { - if (this.loaded) { + public async load(options?: ClerkOptions): Promise { + if (this.status === 'loading') { + logger.warnOnce('Clerk is already loading. Ignoring duplicate call.'); return; } - // Log a development mode warning once - if (this.#instanceType === 'development') { - logger.warnOnce( - 'Clerk: Clerk has been loaded with development keys. Development instances have strict usage limits and should not be used when deploying your application to production. Learn more: https://clerk.com/docs/deployments/overview', - ); + if (this.status === 'ready') { + logger.warnOnce('Clerk is already loaded. Skipping load process.'); + return; } - this.#options = this.#initOptions(options); + this.status = 'loading'; - assertNoLegacyProp(this.#options); + try { + if (this.#instanceType === 'development') { + logger.warnOnce('Clerk is running in development mode. Usage limits apply. Do not use in production.'); + } - if (this.#options.sdkMetadata) { - Clerk.sdkMetadata = this.#options.sdkMetadata; - } + this.#options = this.#initOptions(options); + assertNoLegacyProp(this.#options); - if (this.#options.telemetry !== false) { - this.telemetry = new TelemetryCollector({ - clerkVersion: Clerk.version, - samplingRate: 1, - publishableKey: this.publishableKey, - ...this.#options.telemetry, - }); - } + if (this.#options.sdkMetadata) { + Clerk.sdkMetadata = this.#options.sdkMetadata; + } + + if (this.#options.telemetry !== false) { + this.telemetry = new TelemetryCollector({ + clerkVersion: Clerk.version, + samplingRate: 1, + publishableKey: this.publishableKey, + ...this.#options.telemetry, + }); + } - if (this.#options.standardBrowser) { - this.#loaded = await this.#loadInStandardBrowser(); - } else { - this.#loaded = await this.#loadInNonStandardBrowser(); + const loaded = this.#options.standardBrowser + ? await this.#loadInStandardBrowser() + : await this.#loadInNonStandardBrowser(); + + if (loaded === false) throw new Error('Clerk failed to load'); + + this.status = 'ready'; + } catch { + this.status = 'error'; } - }; + } #isCombinedSignInOrUpFlow(): boolean { return Boolean(!this.#options.signUpUrl && this.#options.signInUrl && !isAbsoluteUrl(this.#options.signInUrl)); diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index 1374addcab6..240d1cce666 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -29,7 +29,7 @@ export function ClerkContextProvider(props: ClerkContextProvider) { React.useEffect(() => { return clerk.addListener(e => setState({ ...e })); - }, []); + }, [clerk]); const derivedState = deriveState(clerkLoaded, state, initialState); const clerkCtx = React.useMemo(() => ({ value: clerk }), [clerkLoaded]); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index ad7367b37b8..149b7f16fa0 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -1,4 +1,4 @@ -import { inBrowser } from '@clerk/shared/browser'; +import { EventEmitter } from '@clerk/shared/event-emitter'; import { loadClerkJsScript } from '@clerk/shared/loadClerkJsScript'; import { handleValueOrFn } from '@clerk/shared/utils'; import type { @@ -105,6 +105,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private readonly options: IsomorphicClerkOptions; private readonly Clerk: ClerkProp; private clerkjs: BrowserClerk | HeadlessBrowserClerk | null = null; + private eventEmitter = new EventEmitter(); private preopenOneTap?: null | GoogleOneTapProps = null; private preopenUserVerification?: null | __internal_UserVerificationProps = null; private preopenSignIn?: null | SignInProps = null; @@ -134,17 +135,27 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { >(); private loadedListeners: Array<() => void> = []; - #loaded = false; #domain: DomainOrProxyUrl['domain']; #proxyUrl: DomainOrProxyUrl['proxyUrl']; #publishableKey: string; + /** + * @private + * @property {LoadedClerk['status']} #status - Represents the current status of the Clerk instance. + * The status is initialized to 'uninitialized' and can be updated to reflect the current state. + */ + #status: LoadedClerk['status'] = 'uninitialized'; + get publishableKey(): string { return this.#publishableKey; } get loaded(): boolean { - return this.#loaded; + return this.#status === 'ready'; + } + + get status() { + return this.#status; } static #instance: IsomorphicClerk | null | undefined; @@ -155,7 +166,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { // Also will recreate the instance if the provided Clerk instance changes // This method should be idempotent in both scenarios if ( - !inBrowser() || + typeof window === 'undefined' || !this.#instance || (options.Clerk && this.#instance.Clerk !== options.Clerk) || // Allow hot swapping PKs on the client @@ -199,13 +210,13 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } constructor(options: IsomorphicClerkOptions) { - const { Clerk = null, publishableKey } = options || {}; + const { Clerk = null, publishableKey } = options ?? {}; this.#publishableKey = publishableKey; this.#proxyUrl = options?.proxyUrl; this.#domain = options?.domain; this.options = options; this.Clerk = Clerk; - this.mode = inBrowser() ? 'browser' : 'server'; + this.mode = typeof window === 'undefined' ? 'server' : 'browser'; if (!this.options.sdkMetadata) { this.options.sdkMetadata = SDK_METADATA; @@ -246,7 +257,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { buildSignInUrl = (opts?: RedirectOptions): string | void => { const callback = () => this.clerkjs?.buildSignInUrl(opts) || ''; - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('buildSignInUrl', callback); @@ -255,7 +266,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { buildSignUpUrl = (opts?: RedirectOptions): string | void => { const callback = () => this.clerkjs?.buildSignUpUrl(opts) || ''; - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('buildSignUpUrl', callback); @@ -264,7 +275,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { buildAfterSignInUrl = (...args: Parameters): string | void => { const callback = () => this.clerkjs?.buildAfterSignInUrl(...args) || ''; - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('buildAfterSignInUrl', callback); @@ -273,7 +284,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { buildAfterSignUpUrl = (...args: Parameters): string | void => { const callback = () => this.clerkjs?.buildAfterSignUpUrl(...args) || ''; - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('buildAfterSignUpUrl', callback); @@ -282,7 +293,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { buildAfterSignOutUrl = (): string | void => { const callback = () => this.clerkjs?.buildAfterSignOutUrl() || ''; - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('buildAfterSignOutUrl', callback); @@ -291,7 +302,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { buildAfterMultiSessionSingleSignOutUrl = (): string | void => { const callback = () => this.clerkjs?.buildAfterMultiSessionSingleSignOutUrl() || ''; - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('buildAfterMultiSessionSingleSignOutUrl', callback); @@ -300,7 +311,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { buildUserProfileUrl = (): string | void => { const callback = () => this.clerkjs?.buildUserProfileUrl() || ''; - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('buildUserProfileUrl', callback); @@ -309,7 +320,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { buildCreateOrganizationUrl = (): string | void => { const callback = () => this.clerkjs?.buildCreateOrganizationUrl() || ''; - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('buildCreateOrganizationUrl', callback); @@ -318,7 +329,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { buildOrganizationProfileUrl = (): string | void => { const callback = () => this.clerkjs?.buildOrganizationProfileUrl() || ''; - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('buildOrganizationProfileUrl', callback); @@ -327,7 +338,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { buildWaitlistUrl = (): string | void => { const callback = () => this.clerkjs?.buildWaitlistUrl() || ''; - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('buildWaitlistUrl', callback); @@ -336,7 +347,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { buildUrlWithAuth = (to: string): string | void => { const callback = () => this.clerkjs?.buildUrlWithAuth(to) || ''; - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('buildUrlWithAuth', callback); @@ -345,7 +356,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { handleUnauthenticated = async () => { const callback = () => this.clerkjs?.handleUnauthenticated(); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { void callback(); } else { this.premountMethodCalls.set('handleUnauthenticated', callback); @@ -360,7 +371,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } async loadClerkJS(): Promise { - if (this.mode !== 'browser' || this.#loaded) { + if (typeof window === 'undefined' || this.loaded) { return; } @@ -374,11 +385,9 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { // For more information refer to: // - https://github.com/remix-run/remix/issues/2947 // - https://github.com/facebook/react/issues/24430 - if (typeof window !== 'undefined') { - window.__clerk_publishable_key = this.#publishableKey; - window.__clerk_proxy_url = this.proxyUrl; - window.__clerk_domain = this.domain; - } + window.__clerk_publishable_key = this.#publishableKey; + window.__clerk_proxy_url = this.proxyUrl; + window.__clerk_domain = this.domain; try { if (this.Clerk) { @@ -441,18 +450,23 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } public addOnLoaded = (cb: () => void) => { - this.loadedListeners.push(cb); - /** - * When IsomorphicClerk is loaded execute the callback directly - */ + this.eventEmitter.once('loaded', cb); if (this.loaded) { - this.emitLoaded(); + this.eventEmitter.emit('loaded'); } }; + public removeOnLoaded(cb: () => void) { + this.eventEmitter.off('loaded', cb); + } + + /** + * Emits the loaded event. + * This is used to notify listeners that the Clerk instance has been loaded. + * This is only here for testing purposes. + */ public emitLoaded = () => { - this.loadedListeners.forEach(cb => cb()); - this.loadedListeners = []; + this.eventEmitter.emit('loaded'); }; private hydrateClerkJS = (clerkjs: BrowserClerk | HeadlessBrowserClerk | undefined) => { @@ -527,8 +541,8 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.__experimental_mountPricingTable(node, props); }); - this.#loaded = true; - this.emitLoaded(); + this.#status = 'ready'; + this.eventEmitter.emit('loaded'); return this.clerkjs; }; @@ -634,7 +648,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; openSignIn = (props?: SignInProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.openSignIn(props); } else { this.preopenSignIn = props; @@ -642,7 +656,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; closeSignIn = () => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.closeSignIn(); } else { this.preopenSignIn = null; @@ -650,7 +664,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; __internal_openReverification = (props?: __internal_UserVerificationModalProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.__internal_openReverification(props); } else { this.preopenUserVerification = props; @@ -658,7 +672,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; __internal_closeReverification = () => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.__internal_closeReverification(); } else { this.preopenUserVerification = null; @@ -666,7 +680,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; openGoogleOneTap = (props?: GoogleOneTapProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.openGoogleOneTap(props); } else { this.preopenOneTap = props; @@ -674,7 +688,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; closeGoogleOneTap = () => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.closeGoogleOneTap(); } else { this.preopenOneTap = null; @@ -682,7 +696,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; openUserProfile = (props?: UserProfileProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.openUserProfile(props); } else { this.preopenUserProfile = props; @@ -690,7 +704,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; closeUserProfile = () => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.closeUserProfile(); } else { this.preopenUserProfile = null; @@ -698,7 +712,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; openOrganizationProfile = (props?: OrganizationProfileProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.openOrganizationProfile(props); } else { this.preopenOrganizationProfile = props; @@ -706,7 +720,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; closeOrganizationProfile = () => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.closeOrganizationProfile(); } else { this.preopenOrganizationProfile = null; @@ -714,7 +728,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; openCreateOrganization = (props?: CreateOrganizationProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.openCreateOrganization(props); } else { this.preopenCreateOrganization = props; @@ -722,7 +736,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; closeCreateOrganization = () => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.closeCreateOrganization(); } else { this.preopenCreateOrganization = null; @@ -730,7 +744,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; openWaitlist = (props?: WaitlistProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.openWaitlist(props); } else { this.preOpenWaitlist = props; @@ -738,7 +752,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; closeWaitlist = () => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.closeWaitlist(); } else { this.preOpenWaitlist = null; @@ -746,7 +760,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; openSignUp = (props?: SignUpProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.openSignUp(props); } else { this.preopenSignUp = props; @@ -754,7 +768,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; closeSignUp = () => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.closeSignUp(); } else { this.preopenSignUp = null; @@ -762,7 +776,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; mountSignIn = (node: HTMLDivElement, props?: SignInProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.mountSignIn(node, props); } else { this.premountSignInNodes.set(node, props); @@ -770,7 +784,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; unmountSignIn = (node: HTMLDivElement) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.unmountSignIn(node); } else { this.premountSignInNodes.delete(node); @@ -778,7 +792,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; __experimental_mountPricingTable = (node: HTMLDivElement, props?: __experimental_PricingTableProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.__experimental_mountPricingTable(node, props); } else { this.premountPricingTableNodes.set(node, props); @@ -786,7 +800,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; __experimental_unmountPricingTable = (node: HTMLDivElement) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.__experimental_unmountPricingTable(node); } else { this.premountPricingTableNodes.delete(node); @@ -794,7 +808,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; mountSignUp = (node: HTMLDivElement, props?: SignUpProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.mountSignUp(node, props); } else { this.premountSignUpNodes.set(node, props); @@ -802,7 +816,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; unmountSignUp = (node: HTMLDivElement) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.unmountSignUp(node); } else { this.premountSignUpNodes.delete(node); @@ -810,7 +824,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; mountUserProfile = (node: HTMLDivElement, props?: UserProfileProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.mountUserProfile(node, props); } else { this.premountUserProfileNodes.set(node, props); @@ -818,7 +832,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; unmountUserProfile = (node: HTMLDivElement) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.unmountUserProfile(node); } else { this.premountUserProfileNodes.delete(node); @@ -826,7 +840,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; mountOrganizationProfile = (node: HTMLDivElement, props?: OrganizationProfileProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.mountOrganizationProfile(node, props); } else { this.premountOrganizationProfileNodes.set(node, props); @@ -834,7 +848,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; unmountOrganizationProfile = (node: HTMLDivElement) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.unmountOrganizationProfile(node); } else { this.premountOrganizationProfileNodes.delete(node); @@ -842,7 +856,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; mountCreateOrganization = (node: HTMLDivElement, props?: CreateOrganizationProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.mountCreateOrganization(node, props); } else { this.premountCreateOrganizationNodes.set(node, props); @@ -850,7 +864,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; unmountCreateOrganization = (node: HTMLDivElement) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.unmountCreateOrganization(node); } else { this.premountCreateOrganizationNodes.delete(node); @@ -858,7 +872,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; mountOrganizationSwitcher = (node: HTMLDivElement, props?: OrganizationSwitcherProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.mountOrganizationSwitcher(node, props); } else { this.premountOrganizationSwitcherNodes.set(node, props); @@ -866,7 +880,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; unmountOrganizationSwitcher = (node: HTMLDivElement) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.unmountOrganizationSwitcher(node); } else { this.premountOrganizationSwitcherNodes.delete(node); @@ -875,7 +889,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { __experimental_prefetchOrganizationSwitcher = () => { const callback = () => this.clerkjs?.__experimental_prefetchOrganizationSwitcher(); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { void callback(); } else { this.premountMethodCalls.set('__experimental_prefetchOrganizationSwitcher', callback); @@ -883,7 +897,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; mountOrganizationList = (node: HTMLDivElement, props?: OrganizationListProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.mountOrganizationList(node, props); } else { this.premountOrganizationListNodes.set(node, props); @@ -891,7 +905,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; unmountOrganizationList = (node: HTMLDivElement) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.unmountOrganizationList(node); } else { this.premountOrganizationListNodes.delete(node); @@ -899,7 +913,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; mountUserButton = (node: HTMLDivElement, userButtonProps?: UserButtonProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.mountUserButton(node, userButtonProps); } else { this.premountUserButtonNodes.set(node, userButtonProps); @@ -907,7 +921,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; unmountUserButton = (node: HTMLDivElement) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.unmountUserButton(node); } else { this.premountUserButtonNodes.delete(node); @@ -915,7 +929,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; mountWaitlist = (node: HTMLDivElement, props?: WaitlistProps) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.mountWaitlist(node, props); } else { this.premountWaitlistNodes.set(node, props); @@ -923,7 +937,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; unmountWaitlist = (node: HTMLDivElement) => { - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { this.clerkjs.unmountWaitlist(node); } else { this.premountWaitlistNodes.delete(node); @@ -948,7 +962,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { navigate = (to: string) => { const callback = () => this.clerkjs?.navigate(to); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { void callback(); } else { this.premountMethodCalls.set('navigate', callback); @@ -957,7 +971,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { redirectWithAuth = async (...args: Parameters) => { const callback = () => this.clerkjs?.redirectWithAuth(...args); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('redirectWithAuth', callback); @@ -967,7 +981,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { redirectToSignIn = async (opts?: SignInRedirectOptions) => { const callback = () => this.clerkjs?.redirectToSignIn(opts as any); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('redirectToSignIn', callback); @@ -977,7 +991,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { redirectToSignUp = async (opts?: SignUpRedirectOptions) => { const callback = () => this.clerkjs?.redirectToSignUp(opts as any); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('redirectToSignUp', callback); @@ -987,7 +1001,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { redirectToUserProfile = async () => { const callback = () => this.clerkjs?.redirectToUserProfile(); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('redirectToUserProfile', callback); @@ -997,7 +1011,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { redirectToAfterSignUp = (): void => { const callback = () => this.clerkjs?.redirectToAfterSignUp(); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('redirectToAfterSignUp', callback); @@ -1006,7 +1020,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { redirectToAfterSignIn = () => { const callback = () => this.clerkjs?.redirectToAfterSignIn(); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { callback(); } else { this.premountMethodCalls.set('redirectToAfterSignIn', callback); @@ -1015,7 +1029,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { redirectToAfterSignOut = () => { const callback = () => this.clerkjs?.redirectToAfterSignOut(); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { callback(); } else { this.premountMethodCalls.set('redirectToAfterSignOut', callback); @@ -1024,7 +1038,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { redirectToOrganizationProfile = async () => { const callback = () => this.clerkjs?.redirectToOrganizationProfile(); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('redirectToOrganizationProfile', callback); @@ -1034,7 +1048,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { redirectToCreateOrganization = async () => { const callback = () => this.clerkjs?.redirectToCreateOrganization(); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('redirectToCreateOrganization', callback); @@ -1044,7 +1058,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { redirectToWaitlist = async () => { const callback = () => this.clerkjs?.redirectToWaitlist(); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback(); } else { this.premountMethodCalls.set('redirectToWaitlist', callback); @@ -1054,7 +1068,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { handleRedirectCallback = async (params: HandleOAuthCallbackParams): Promise => { const callback = () => this.clerkjs?.handleRedirectCallback(params); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { void callback()?.catch(() => { // This error is caused when the host app is using React18 // and strictMode is enabled. This useEffects runs twice because @@ -1074,7 +1088,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { params: HandleOAuthCallbackParams, ): Promise => { const callback = () => this.clerkjs?.handleGoogleOneTapCallback(signInOrUp, params); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { void callback()?.catch(() => { // This error is caused when the host app is using React18 // and strictMode is enabled. This useEffects runs twice because @@ -1091,7 +1105,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { handleEmailLinkVerification = async (params: HandleEmailLinkVerificationParams) => { const callback = () => this.clerkjs?.handleEmailLinkVerification(params); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback() as Promise; } else { this.premountMethodCalls.set('handleEmailLinkVerification', callback); @@ -1100,7 +1114,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { authenticateWithMetamask = async (params?: AuthenticateWithMetamaskParams) => { const callback = () => this.clerkjs?.authenticateWithMetamask(params); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback() as Promise; } else { this.premountMethodCalls.set('authenticateWithMetamask', callback); @@ -1109,7 +1123,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { authenticateWithCoinbaseWallet = async (params?: AuthenticateWithCoinbaseWalletParams) => { const callback = () => this.clerkjs?.authenticateWithCoinbaseWallet(params); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback() as Promise; } else { this.premountMethodCalls.set('authenticateWithCoinbaseWallet', callback); @@ -1118,7 +1132,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { authenticateWithOKXWallet = async (params?: AuthenticateWithOKXWalletParams) => { const callback = () => this.clerkjs?.authenticateWithOKXWallet(params); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback() as Promise; } else { this.premountMethodCalls.set('authenticateWithOKXWallet', callback); @@ -1127,7 +1141,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { authenticateWithWeb3 = async (params: ClerkAuthenticateWithWeb3Params) => { const callback = () => this.clerkjs?.authenticateWithWeb3(params); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback() as Promise; } else { this.premountMethodCalls.set('authenticateWithWeb3', callback); @@ -1141,7 +1155,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { createOrganization = async (params: CreateOrganizationParams): Promise => { const callback = () => this.clerkjs?.createOrganization(params); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback() as Promise; } else { this.premountMethodCalls.set('createOrganization', callback); @@ -1150,7 +1164,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { getOrganization = async (organizationId: string): Promise => { const callback = () => this.clerkjs?.getOrganization(organizationId); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback() as Promise; } else { this.premountMethodCalls.set('getOrganization', callback); @@ -1159,7 +1173,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { joinWaitlist = async (params: JoinWaitlistParams): Promise => { const callback = () => this.clerkjs?.joinWaitlist(params); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback() as Promise; } else { this.premountMethodCalls.set('joinWaitlist', callback); @@ -1168,7 +1182,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { signOut = async (...args: Parameters) => { const callback = () => this.clerkjs?.signOut(...args); - if (this.clerkjs && this.#loaded) { + if (this.clerkjs && this.loaded) { return callback() as Promise; } else { this.premountMethodCalls.set('signOut', callback); diff --git a/packages/shared/package.json b/packages/shared/package.json index d5ee062fd42..655ad98c3f5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -75,19 +75,22 @@ "main": "./dist/index.js", "files": [ "dist", - "scripts", - "authorization", + "apiUrlFromPublishableKey", "authorization-errors", + "authorization", "browser", - "retry", "color", + "constants", "cookie", "date", "deprecated", "deriveState", + "devBrowser", "dom", "error", + "event-emitter", "file", + "getEnvVariable", "globs", "handleValueOrFn", "isomorphicAtob", @@ -96,28 +99,26 @@ "loadClerkJsScript", "loadScript", "localStorageBroadcastChannel", + "logger", + "oauth", + "object", + "organization", + "pathMatcher", + "pathToRegexp", "poller", "proxy", - "underscore", - "url", - "versionSelector", "react", - "constants", - "apiUrlFromPublishableKey", - "telemetry", - "logger", - "webauthn", + "retry", "router", - "pathToRegexp", + "scripts", + "telemetry", + "underscore", + "url", "utils", - "workerTimers", - "devBrowser", - "object", - "oauth", + "versionSelector", "web3", - "getEnvVariable", - "pathMatcher", - "organization" + "webauthn", + "workerTimers" ], "scripts": { "build": "tsup", diff --git a/packages/shared/src/__tests__/event-emitter.ts b/packages/shared/src/__tests__/event-emitter.ts new file mode 100644 index 00000000000..bedf533a44c --- /dev/null +++ b/packages/shared/src/__tests__/event-emitter.ts @@ -0,0 +1,169 @@ +import { EventEmitter } from '../event-emitter'; + +describe('EventEmitter', () => { + let emitter: EventEmitter; + + beforeEach(() => { + emitter = new EventEmitter(); + }); + + it('calls event listeners when an event is emitted', () => { + const callback = jest.fn(); + emitter.on('testEvent', callback); + emitter.emit('testEvent'); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('passes arguments to event listeners', () => { + const callback = jest.fn(); + emitter.on('dataEvent', callback); + emitter.emit('dataEvent', 'hello', 42); + + expect(callback).toHaveBeenCalledWith('hello', 42); + }); + + it('supports multiple listeners for the same event', () => { + const cb1 = jest.fn(); + const cb2 = jest.fn(); + + emitter.on('multiEvent', cb1); + emitter.on('multiEvent', cb2); + emitter.emit('multiEvent'); + + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + }); + + it('removes a specific listener and prevents it from being called', () => { + const callback = jest.fn(); + emitter.on('removeEvent', callback); + emitter.off('removeEvent', callback); + emitter.emit('removeEvent'); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('removes all listeners for a given event', () => { + const cb1 = jest.fn(); + const cb2 = jest.fn(); + + emitter.on('clearEvent', cb1); + emitter.on('clearEvent', cb2); + emitter.clear('clearEvent'); + emitter.emit('clearEvent'); + + expect(cb1).not.toHaveBeenCalled(); + expect(cb2).not.toHaveBeenCalled(); + }); + + it('clears all listeners when no event is specified', () => { + const cb1 = jest.fn(); + const cb2 = jest.fn(); + + emitter.on('event1', cb1); + emitter.on('event2', cb2); + emitter.clear(); + emitter.emit('event1'); + emitter.emit('event2'); + + expect(cb1).not.toHaveBeenCalled(); + expect(cb2).not.toHaveBeenCalled(); + }); + + it('ignores removal of a non-existent listener', () => { + const callback = jest.fn(); + emitter.off('nonExistentEvent', callback); + emitter.emit('nonExistentEvent'); + + expect(callback).not.toHaveBeenCalled(); // Ensures no crash or unwanted behavior + }); + + it('handles emitting an event with no listeners gracefully', () => { + expect(() => emitter.emit('emptyEvent')).not.toThrow(); + }); + + it('removes a listener while executing the event without affecting execution', () => { + const callback = jest.fn(() => { + emitter.off('selfRemoveEvent', callback); + }); + + emitter.on('selfRemoveEvent', callback); + emitter.emit('selfRemoveEvent'); + emitter.emit('selfRemoveEvent'); // Should not call callback again + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('allows multiple removals of the same listener without breaking', () => { + const cb1 = jest.fn(); + const cb2 = jest.fn(); + + emitter.on('doubleRemoveEvent', cb1); + emitter.on('doubleRemoveEvent', cb2); + emitter.off('doubleRemoveEvent', cb1); + emitter.off('doubleRemoveEvent', cb1); // Removing twice shouldn't cause issues + + emitter.emit('doubleRemoveEvent'); + + expect(cb1).not.toHaveBeenCalled(); + expect(cb2).toHaveBeenCalledTimes(1); + }); + + describe('once', () => { + it('executes a one-time listener only once', () => { + const callback = jest.fn(); + emitter.once('onceEvent', callback); + + emitter.emit('onceEvent'); + emitter.emit('onceEvent'); // Should not trigger again + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('passes arguments to a one-time listener', () => { + const callback = jest.fn(); + emitter.once('onceArgsEvent', callback); + + emitter.emit('onceArgsEvent', 'hello', 42); + + expect(callback).toHaveBeenCalledWith('hello', 42); + }); + + it('removes a one-time listener automatically after execution', () => { + const callback = jest.fn(); + emitter.once('autoRemoveEvent', callback); + + emitter.emit('autoRemoveEvent'); + + // Emitting again should not call the callback + emitter.emit('autoRemoveEvent'); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('does not affect other listeners on the same event', () => { + const onceCallback = jest.fn(); + const regularCallback = jest.fn(); + + emitter.once('mixedEvent', onceCallback); + emitter.on('mixedEvent', regularCallback); + + emitter.emit('mixedEvent'); + emitter.emit('mixedEvent'); // Only `regularCallback` should fire again + + expect(onceCallback).toHaveBeenCalledTimes(1); + expect(regularCallback).toHaveBeenCalledTimes(2); + }); + + it('does not call the one-time listener if removed before execution', () => { + const callback = jest.fn(); + emitter.once('removeBeforeEvent', callback); + + emitter.off('removeBeforeEvent', callback); + emitter.emit('removeBeforeEvent'); + + expect(callback).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/shared/src/__tests__/loadClerkJsScript.test.ts b/packages/shared/src/__tests__/loadClerkJsScript.test.ts index 2e259e10292..859de8e479c 100644 --- a/packages/shared/src/__tests__/loadClerkJsScript.test.ts +++ b/packages/shared/src/__tests__/loadClerkJsScript.test.ts @@ -60,7 +60,7 @@ describe('loadClerkJsScript(options)', () => { const loadPromise = loadClerkJsScript({ publishableKey: mockPublishableKey }); mockExistingScript.dispatchEvent(new Event('error')); - await expect(loadPromise).rejects.toBe('Clerk: Failed to load Clerk'); + await expect(loadPromise).rejects.toThrow('Clerk: Failed to load Clerk'); expect(loadScript).not.toHaveBeenCalled(); }); diff --git a/packages/shared/src/event-emitter.ts b/packages/shared/src/event-emitter.ts new file mode 100644 index 00000000000..080afdd9f29 --- /dev/null +++ b/packages/shared/src/event-emitter.ts @@ -0,0 +1,86 @@ +/** + * A simple event emitter class for managing event-driven behavior. + */ +export class EventEmitter { + /** + * Stores event listeners, mapping event names to sets of callback functions. + */ + private events: Map void>> = new Map(); + + /** + * Registers a new listener for a specific event. + * + * @param event - The name of the event to listen for. + * @param listener - The callback function to execute when the event is emitted. + */ + on(event: string, listener: (...args: any[]) => void): void { + let listeners = this.events.get(event); + if (!listeners) { + listeners = new Set(); + this.events.set(event, listeners); + } + listeners.add(listener); + } + + /** + * Registers a one-time listener for a specific event. + * The listener is automatically removed after being called once. + * + * @param event - The name of the event to listen for. + * @param listener - The callback function to execute when the event is emitted. + */ + once(event: string, listener: (...args: any[]) => void): void { + const onceWrapper = (...args: any[]) => { + this.off(event, onceWrapper); // Remove after first execution + listener(...args); + }; + + // Store a reference mapping for proper removal + Object.defineProperty(listener, '__onceWrapper', { value: onceWrapper }); + + this.on(event, onceWrapper); + } + + /** + * Removes a specific listener from an event. + * + * @param event - The name of the event. + * @param listener - The callback function to remove. + */ + off(event: string, listener: (...args: any[]) => void): void { + const listeners = this.events.get(event); + if (listeners) { + const wrappedListener = (listener as any).__onceWrapper || listener; + listeners.delete(wrappedListener); + if (listeners.size === 0) { + this.events.delete(event); + } + } + } + + /** + * Emits an event, calling all registered listeners for that event. + * + * @param event - The name of the event to emit. + * @param args - Optional arguments to pass to the event listeners. + */ + emit(event: string, ...args: any[]): void { + const listeners = this.events.get(event); + if (listeners) { + listeners.forEach(listener => listener(...args)); + } + } + + /** + * Removes all listeners for a specific event or clears all events if no event name is provided. + * + * @param event - The name of the event to clear. If omitted, all events will be cleared. + */ + clear(event?: string): void { + if (event) { + this.events.delete(event); + } else { + this.events.clear(); + } + } +} diff --git a/packages/shared/src/loadClerkJsScript.ts b/packages/shared/src/loadClerkJsScript.ts index 97647e3f0bd..24e047ef186 100644 --- a/packages/shared/src/loadClerkJsScript.ts +++ b/packages/shared/src/loadClerkJsScript.ts @@ -57,7 +57,7 @@ const loadClerkJsScript = async (opts?: LoadClerkJsScriptOptions) => { }); existingScript.addEventListener('error', () => { - reject(FAILED_TO_LOAD_ERROR); + reject(new Error(FAILED_TO_LOAD_ERROR)); }); }); } @@ -133,9 +133,9 @@ const buildClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => { const applyClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => (script: HTMLScriptElement) => { const attributes = buildClerkJsScriptAttributes(options); - for (const attribute in attributes) { - script.setAttribute(attribute, attributes[attribute]); - } + Object.entries(attributes).forEach(([key, value]) => { + script.setAttribute(key, value); + }); }; export { loadClerkJsScript, buildClerkJsScriptAttributes, clerkJsScriptUrl }; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 005099b9b9e..9215ece83ec 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -63,6 +63,8 @@ export type SDKMetadata = { environment?: string; }; +export type Status = 'degraded' | 'error' | 'loading' | 'ready' | 'uninitialized'; + export type ListenerCallback = (emission: Resources) => void; export type UnsubscribeCallback = () => void; export type BeforeEmitCallback = (session?: SignedInSessionResource | null) => void | Promise; @@ -110,6 +112,11 @@ export interface Clerk { */ loaded: boolean; + /** + * The current loading status of the Clerk SDK. + */ + status: Status; + /** * @internal */