From 45a00ed0965a7e3d6b86d01c99467acf9134b16e Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 3 Sep 2025 21:01:48 +0300 Subject: [PATCH 1/3] fix(clerk-js, shared): Auto revalidate hook on mutations --- packages/clerk-js/src/core/clerk.ts | 4 ++++ .../src/core/resources/CommerceCheckout.ts | 7 ++++-- packages/shared/src/clerkEventBus.ts | 1 + .../src/react/hooks/useSubscription.tsx | 22 +++++++++++++++++-- packages/types/src/clerk.ts | 1 + 5 files changed, 31 insertions(+), 4 deletions(-) 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/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..00349a54156 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, useEffect } from 'react'; import { eventMethodCalled } from '../../telemetry/events'; import { useSWR } from '../clerk-swr'; @@ -22,6 +22,8 @@ type UseSubscriptionParams = { keepPreviousData?: boolean; }; +const revalidateOnEvents: ClerkEventPayload['resource:action'][] = ['checkout.confirm']; + /** * @internal * @@ -55,6 +57,22 @@ export const useSubscription = (params?: UseSubscriptionParams) => { const revalidate = useCallback(() => swr.mutate(), [swr.mutate]); + useEffect(() => { + const on = clerk.on.bind(clerk); + const off = clerk.off.bind(clerk); + const handler = (payload: ClerkEventPayload['resource:action']) => { + if (revalidateOnEvents.includes(payload)) { + // When multiple handlers call `revalidate` the request will fire only once. + void revalidate(); + } + }; + + on('resource:action', handler); + return () => { + off('resource:action', handler); + }; + }, [revalidate]); + return { data: swr.data, error: swr.error, diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index e7b9c0c2faa..131246e4735 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'; }; type OnEventListener = (event: E, handler: EventHandler, opt?: { notify: boolean }) => void; type OffEventListener = (event: E, handler: EventHandler) => void; From 7bff9afbdceaf4c05eb82110d9bef9bd7d05cfbb Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 3 Sep 2025 23:11:53 +0300 Subject: [PATCH 2/3] fix deduping --- .../core/resources/CommerceSubscription.ts | 2 + .../src/ui/contexts/components/Plans.tsx | 7 +- packages/react/src/isomorphicClerk.ts | 5 + .../src/react/hooks/useSubscription.tsx | 62 +++++----- .../src/react/hooks/useThrottledEvent.tsx | 115 ++++++++++++++++++ packages/types/src/clerk.ts | 2 +- 6 files changed, 157 insertions(+), 36 deletions(-) create mode 100644 packages/shared/src/react/hooks/useThrottledEvent.tsx 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/react/hooks/useSubscription.tsx b/packages/shared/src/react/hooks/useSubscription.tsx index 00349a54156..aa8a282bd47 100644 --- a/packages/shared/src/react/hooks/useSubscription.tsx +++ b/packages/shared/src/react/hooks/useSubscription.tsx @@ -1,14 +1,15 @@ import type { ClerkEventPayload, ForPayerType } from '@clerk/types'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useMemo } from 'react'; import { eventMethodCalled } from '../../telemetry/events'; -import { useSWR } from '../clerk-swr'; +import { unstable_serialize, useSWR } from '../clerk-swr'; import { useAssertWrappedByClerkProvider, useClerkInstanceContext, useOrganizationContext, useUserContext, } from '../contexts'; +import { useThrottledEvent } from './useThrottledEvent'; const hookName = 'useSubscription'; @@ -22,7 +23,7 @@ type UseSubscriptionParams = { keepPreviousData?: boolean; }; -const revalidateOnEvents: ClerkEventPayload['resource:action'][] = ['checkout.confirm']; +const revalidateOnEvents: ClerkEventPayload['resource:action'][] = ['checkout.confirm', 'subscriptionItem.cancel']; /** * @internal @@ -40,38 +41,37 @@ 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 revalidate = useCallback(() => swr.mutate(), [swr.mutate]); + const uniqueSWRKey = useMemo(() => unstable_serialize(key), [key]); + + const swr = useSWR(key, key => clerk.billing.getSubscription(key.args), { + dedupingInterval: 1_000 * 60, + keepPreviousData: params?.keepPreviousData, + revalidateOnFocus: false, + }); - useEffect(() => { - const on = clerk.on.bind(clerk); - const off = clerk.off.bind(clerk); - const handler = (payload: ClerkEventPayload['resource:action']) => { - if (revalidateOnEvents.includes(payload)) { - // When multiple handlers call `revalidate` the request will fire only once. - void revalidate(); - } - }; + const revalidate = useCallback(() => swr.mutate(), [swr.mutate]); - on('resource:action', handler); - return () => { - off('resource:action', handler); - }; - }, [revalidate]); + // 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: uniqueSWRKey, + events: revalidateOnEvents, + onEvent: revalidate, + clerk, + }); return { data: swr.data, 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 131246e4735..96036f4138d 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -149,7 +149,7 @@ type ClerkEvent = keyof ClerkEventPayload; type EventHandler = (payload: ClerkEventPayload[E]) => void; export type ClerkEventPayload = { status: ClerkStatus; - 'resource:action': 'checkout.confirm'; + 'resource:action': 'checkout.confirm' | 'subscriptionItem.cancel'; }; type OnEventListener = (event: E, handler: EventHandler, opt?: { notify: boolean }) => void; type OffEventListener = (event: E, handler: EventHandler) => void; From f174b51d6c65b2b20338ba804d668a3044f131fe Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 3 Sep 2025 23:13:43 +0300 Subject: [PATCH 3/3] remove swr serialize --- packages/shared/src/react/hooks/useSubscription.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/react/hooks/useSubscription.tsx b/packages/shared/src/react/hooks/useSubscription.tsx index aa8a282bd47..b1a5adae9a6 100644 --- a/packages/shared/src/react/hooks/useSubscription.tsx +++ b/packages/shared/src/react/hooks/useSubscription.tsx @@ -2,7 +2,7 @@ import type { ClerkEventPayload, ForPayerType } from '@clerk/types'; import { useCallback, useMemo } from 'react'; import { eventMethodCalled } from '../../telemetry/events'; -import { unstable_serialize, useSWR } from '../clerk-swr'; +import { useSWR } from '../clerk-swr'; import { useAssertWrappedByClerkProvider, useClerkInstanceContext, @@ -53,7 +53,7 @@ export const useSubscription = (params?: UseSubscriptionParams) => { [user?.id, organization?.id, params?.for], ); - const uniqueSWRKey = useMemo(() => unstable_serialize(key), [key]); + const serializedKey = useMemo(() => JSON.stringify(key), [key]); const swr = useSWR(key, key => clerk.billing.getSubscription(key.args), { dedupingInterval: 1_000 * 60, @@ -67,7 +67,7 @@ export const useSubscription = (params?: UseSubscriptionParams) => { // `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: uniqueSWRKey, + uniqueKey: serializedKey, events: revalidateOnEvents, onEvent: revalidate, clerk,