diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 807942113bf..38891da4f64 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -239,6 +239,10 @@ export class Clerk implements ClerkInterface { #touchThrottledUntil = 0; #publicEventBus = createClerkEventBus(); + get __internal_eventBus() { + return this.#publicEventBus; + } + public __internal_getCachedResources: | (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>) | undefined; diff --git a/packages/clerk-js/src/core/resources/CommerceCheckout.ts b/packages/clerk-js/src/core/resources/CommerceCheckout.ts index 57a8cc25c50..7b8fc139ae8 100644 --- a/packages/clerk-js/src/core/resources/CommerceCheckout.ts +++ b/packages/clerk-js/src/core/resources/CommerceCheckout.ts @@ -53,11 +53,11 @@ export class CommerceCheckout extends BaseResource implements CommerceCheckoutRe return this; } - confirm = (params: ConfirmCheckoutParams): Promise => { + confirm = async (params: ConfirmCheckoutParams): Promise => { // Retry confirmation in case of a 500 error // This will retry up to 3 times with an increasing delay // It retries at 2s, 4s, 6s and 8s - return retry( + const res = await retry( () => this._basePatch({ path: this.payer.organizationId @@ -84,5 +84,8 @@ export class CommerceCheckout extends BaseResource implements CommerceCheckoutRe }, }, ); + + CommerceCheckout.clerk.__internal_eventBus.emit('resource:action', 'checkout.confirm'); + return res; }; } diff --git a/packages/clerk-js/src/core/resources/CommerceSubscription.ts b/packages/clerk-js/src/core/resources/CommerceSubscription.ts index 5677cff4f01..b789fb5002f 100644 --- a/packages/clerk-js/src/core/resources/CommerceSubscription.ts +++ b/packages/clerk-js/src/core/resources/CommerceSubscription.ts @@ -117,6 +117,8 @@ export class CommerceSubscriptionItem extends BaseResource implements CommerceSu }) )?.response as unknown as DeletedObjectJSON; + CommerceSubscription.clerk.__internal_eventBus.emit('resource:action', 'subscriptionItem.cancel'); + return new DeletedObject(json); } } diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index f426e359447..ac51f54f1cc 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -115,7 +115,7 @@ export const usePlansContext = () => { return false; }, [clerk, subscriberType]); - const { subscriptionItems, revalidate: revalidateSubscriptions, data: topLevelSubscription } = useSubscription(); + const { subscriptionItems, data: topLevelSubscription } = useSubscription(); // Invalidates cache but does not fetch immediately const { data: plans, revalidate: revalidatePlans } = usePlans({ mode: 'cache' }); @@ -126,12 +126,11 @@ export const usePlansContext = () => { const { revalidate: revalidatePaymentSources } = usePaymentMethods(); const revalidateAll = useCallback(() => { - // Revalidate the plans and subscriptions - void revalidateSubscriptions(); + // Revalidate the plans void revalidatePlans(); void revalidateStatements(); void revalidatePaymentSources(); - }, [revalidateSubscriptions, revalidatePlans, revalidateStatements, revalidatePaymentSources]); + }, [revalidatePlans, revalidateStatements, revalidatePaymentSources]); // should the default plan be shown as active const isDefaultPlanImplicitlyActiveOrUpcoming = useMemo(() => { diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 3fd9bf7061c..7b99866eda1 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -565,6 +565,11 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { this.on('status', listener, { notify: true }); }); + this.#eventBus.internal.retrieveListeners('resource:action')?.forEach(listener => { + // Since clerkjs exists it will call `this.clerkjs.on('status', listener)` + this.on('resource:action', listener, { notify: true }); + }); + if (this.preopenSignIn !== null) { clerkjs.openSignIn(this.preopenSignIn); } diff --git a/packages/shared/src/clerkEventBus.ts b/packages/shared/src/clerkEventBus.ts index bdf9bdfa73c..6d75c8960f9 100644 --- a/packages/shared/src/clerkEventBus.ts +++ b/packages/shared/src/clerkEventBus.ts @@ -4,6 +4,7 @@ import { createEventBus } from './eventBus'; export const clerkEvents = { Status: 'status', + ResourceAction: 'resource:action', } satisfies Record; export const createClerkEventBus = () => { diff --git a/packages/shared/src/react/hooks/useSubscription.tsx b/packages/shared/src/react/hooks/useSubscription.tsx index 53efd1e2fd2..b1a5adae9a6 100644 --- a/packages/shared/src/react/hooks/useSubscription.tsx +++ b/packages/shared/src/react/hooks/useSubscription.tsx @@ -1,5 +1,5 @@ -import type { ForPayerType } from '@clerk/types'; -import { useCallback } from 'react'; +import type { ClerkEventPayload, ForPayerType } from '@clerk/types'; +import { useCallback, useMemo } from 'react'; import { eventMethodCalled } from '../../telemetry/events'; import { useSWR } from '../clerk-swr'; @@ -9,6 +9,7 @@ import { useOrganizationContext, useUserContext, } from '../contexts'; +import { useThrottledEvent } from './useThrottledEvent'; const hookName = 'useSubscription'; @@ -22,6 +23,8 @@ type UseSubscriptionParams = { keepPreviousData?: boolean; }; +const revalidateOnEvents: ClerkEventPayload['resource:action'][] = ['checkout.confirm', 'subscriptionItem.cancel']; + /** * @internal * @@ -38,23 +41,38 @@ export const useSubscription = (params?: UseSubscriptionParams) => { clerk.telemetry?.record(eventMethodCalled(hookName)); - const swr = useSWR( - user?.id - ? { - type: 'commerce-subscription', - userId: user.id, - args: { orgId: params?.for === 'organization' ? organization?.id : undefined }, - } - : null, - ({ args }) => clerk.billing.getSubscription(args), - { - dedupingInterval: 1_000 * 60, - keepPreviousData: params?.keepPreviousData, - }, + const key = useMemo( + () => + user?.id + ? { + type: 'commerce-subscription', + userId: user.id, + args: { orgId: params?.for === 'organization' ? organization?.id : undefined }, + } + : null, + [user?.id, organization?.id, params?.for], ); + const serializedKey = useMemo(() => JSON.stringify(key), [key]); + + const swr = useSWR(key, key => clerk.billing.getSubscription(key.args), { + dedupingInterval: 1_000 * 60, + keepPreviousData: params?.keepPreviousData, + revalidateOnFocus: false, + }); + const revalidate = useCallback(() => swr.mutate(), [swr.mutate]); + // Maps cache key to event listener instead of matching the hook instance. + // `swr.mutate` does not dedupe, N parallel calles will fire N revalidation requests. + // To avoid this, we use `useThrottledEvent` to dedupe the revalidation requests. + useThrottledEvent({ + uniqueKey: serializedKey, + events: revalidateOnEvents, + onEvent: revalidate, + clerk, + }); + return { data: swr.data, error: swr.error, diff --git a/packages/shared/src/react/hooks/useThrottledEvent.tsx b/packages/shared/src/react/hooks/useThrottledEvent.tsx new file mode 100644 index 00000000000..0435aff51db --- /dev/null +++ b/packages/shared/src/react/hooks/useThrottledEvent.tsx @@ -0,0 +1,115 @@ +import type { ClerkEventPayload } from '@clerk/types'; +import { useEffect } from 'react'; + +import type { useClerkInstanceContext } from '../contexts'; + +/** + * Global registry to track event listeners by uniqueKey. + * This prevents duplicate event listeners when multiple hook instances share the same key. + */ +type ThrottledEventRegistry = { + refCount: number; + handler: (payload: ClerkEventPayload['resource:action']) => void; + cleanup: () => void; +}; + +const throttledEventRegistry = new Map(); + +type UseThrottledEventParams = { + /** + * Unique key to identify this event listener. Multiple hooks with the same key + * will share a single event listener. + */ + uniqueKey: string | null; + /** + * Array of events that should trigger the handler. + */ + events: ClerkEventPayload['resource:action'][]; + /** + * Handler function to call when matching events occur. + */ + onEvent: (payload: ClerkEventPayload['resource:action']) => void; + /** + * Clerk instance for event subscription. + */ + clerk: ReturnType; +}; + +/** + * Custom hook that manages event listeners with reference counting to prevent + * duplicate listeners when multiple hook instances share the same uniqueKey. + * This effectively "throttles" event registration by ensuring only one listener + * exists per unique key, regardless of how many components use the same key. + * + * @param params - Configuration for the event listener. + * @param params.uniqueKey - Unique key to identify this event listener. Multiple hooks with the same key will share a single event listener. + * @param params.events - Array of events that should trigger the handler. + * @param params.onEvent - Handler function to call when matching events occur. + * @param params.clerk - Clerk instance for event subscription. + * @example + * ```tsx + * useThrottledEvent({ + * uniqueKey: 'commerce-data-user123', + * events: ['checkout.confirm'], + * onEvent: (payload) => { + * // Handle the event - this will only be registered once + * // even if multiple components use the same uniqueKey + * revalidateData(); + * }, + * clerk: clerkInstance + * }); + * ``` + */ +export const useThrottledEvent = ({ uniqueKey, events, onEvent, clerk }: UseThrottledEventParams) => { + useEffect(() => { + // Only proceed if we have a valid key + if (!uniqueKey) return; + + const existingEntry = throttledEventRegistry.get(uniqueKey); + + if (existingEntry) { + // Increment reference count for existing event listener + existingEntry.refCount++; + } else { + // Create new event listener entry + const on = clerk.on.bind(clerk); + const off = clerk.off.bind(clerk); + + const handler = (payload: ClerkEventPayload['resource:action']) => { + if (events.includes(payload)) { + onEvent(payload); + } + }; + + const cleanup = () => { + off('resource:action', handler); + }; + + // Register the event listener + on('resource:action', handler); + + // Store in registry with initial ref count of 1 + throttledEventRegistry.set(uniqueKey, { + refCount: 1, + handler, + cleanup, + }); + } + + // Cleanup function + return () => { + if (!uniqueKey) return; + + const entry = throttledEventRegistry.get(uniqueKey); + if (entry) { + entry.refCount--; + + // If no more references, cleanup and remove from registry + if (entry.refCount <= 0) { + entry.cleanup(); + throttledEventRegistry.delete(uniqueKey); + } + } + }; + }, [uniqueKey, events, onEvent, clerk]); +}; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index e7b9c0c2faa..96036f4138d 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -149,6 +149,7 @@ type ClerkEvent = keyof ClerkEventPayload; type EventHandler = (payload: ClerkEventPayload[E]) => void; export type ClerkEventPayload = { status: ClerkStatus; + 'resource:action': 'checkout.confirm' | 'subscriptionItem.cancel'; }; type OnEventListener = (event: E, handler: EventHandler, opt?: { notify: boolean }) => void; type OffEventListener = (event: E, handler: EventHandler) => void;