Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions packages/clerk-js/src/core/resources/CommerceCheckout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ export class CommerceCheckout extends BaseResource implements CommerceCheckoutRe
return this;
}

confirm = (params: ConfirmCheckoutParams): Promise<this> => {
confirm = async (params: ConfirmCheckoutParams): Promise<this> => {
// 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
Expand All @@ -84,5 +84,8 @@ export class CommerceCheckout extends BaseResource implements CommerceCheckoutRe
},
},
);

CommerceCheckout.clerk.__internal_eventBus.emit('resource:action', 'checkout.confirm');
return res;
};
}
2 changes: 2 additions & 0 deletions packages/clerk-js/src/core/resources/CommerceSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
7 changes: 3 additions & 4 deletions packages/clerk-js/src/ui/contexts/components/Plans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand All @@ -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(() => {
Expand Down
5 changes: 5 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/clerkEventBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createEventBus } from './eventBus';

export const clerkEvents = {
Status: 'status',
ResourceAction: 'resource:action',
} satisfies Record<string, keyof ClerkEventPayload>;

export const createClerkEventBus = () => {
Expand Down
48 changes: 33 additions & 15 deletions packages/shared/src/react/hooks/useSubscription.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,6 +9,7 @@ import {
useOrganizationContext,
useUserContext,
} from '../contexts';
import { useThrottledEvent } from './useThrottledEvent';

const hookName = 'useSubscription';

Expand All @@ -22,6 +23,8 @@ type UseSubscriptionParams = {
keepPreviousData?: boolean;
};

const revalidateOnEvents: ClerkEventPayload['resource:action'][] = ['checkout.confirm', 'subscriptionItem.cancel'];

/**
* @internal
*
Expand All @@ -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],
);
Comment on lines +44 to 54
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Don’t fetch before org context is ready (prevents wrong-target queries)

When params?.for === 'organization' but organization?.id is not yet available, the hook still returns a non-null SWR key and calls getSubscription({ orgId: undefined }). Gate the key until organization.id exists to avoid misrouted or failing requests.

Apply:

-  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 key = useMemo(() => {
+    if (!user?.id) return null;
+    const isOrg = params?.for === 'organization';
+    if (isOrg && !organization?.id) return null;
+    return {
+      type: 'commerce-subscription' as const,
+      userId: user.id,
+      args: isOrg ? { orgId: organization!.id } : {},
+    };
+  }, [user?.id, organization?.id, params?.for]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 key = useMemo(() => {
if (!user?.id) return null;
const isOrg = params?.for === 'organization';
if (isOrg && !organization?.id) return null;
return {
type: 'commerce-subscription' as const,
userId: user.id,
args: isOrg ? { orgId: organization.id } : {},
};
}, [user?.id, organization?.id, params?.for]);
🤖 Prompt for AI Agents
In packages/shared/src/react/hooks/useSubscription.tsx around lines 44 to 54,
the SWR key is created even when params?.for === 'organization' but
organization?.id is not yet available, which causes getSubscription to be called
with orgId: undefined; change the useMemo so it returns null when the user is
missing or when params?.for === 'organization' and organization?.id is
falsy—i.e., gate the key on organization?.id existence—so SWR won't fetch until
the org context is ready; ensure the dependency array still includes user?.id,
organization?.id, and params?.for.


const serializedKey = useMemo(() => JSON.stringify(key), [key]);

Comment on lines +56 to +57
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix “null” uniqueKey leak into event registry

JSON.stringify(null) is the string "null", which is truthy. This registers a shared listener for all not-ready hooks, wasting work and risking cross-instance coupling. Return undefined when the key is null so useThrottledEvent skips registration.

-  const serializedKey = useMemo(() => JSON.stringify(key), [key]);
+  const serializedKey = useMemo(() => (key ? JSON.stringify(key) : undefined), [key]);
🤖 Prompt for AI Agents
In packages/shared/src/react/hooks/useSubscription.tsx around lines 56 to 57,
JSON.stringify(key) currently turns a null key into the string "null" which
causes a shared listener to be registered; change the memo so that when key is
null or undefined it returns undefined (not the string "null") and otherwise
returns the JSON string—this ensures useThrottledEvent will skip registration
for not-ready hooks.

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,
Expand Down
115 changes: 115 additions & 0 deletions packages/shared/src/react/hooks/useThrottledEvent.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ThrottledEventRegistry>();

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<typeof useClerkInstanceContext>;
};

/**
* 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]);
};
1 change: 1 addition & 0 deletions packages/types/src/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
type EventHandler<E extends ClerkEvent> = (payload: ClerkEventPayload[E]) => void;
export type ClerkEventPayload = {
status: ClerkStatus;
'resource:action': 'checkout.confirm' | 'subscriptionItem.cancel';
};
type OnEventListener = <E extends ClerkEvent>(event: E, handler: EventHandler<E>, opt?: { notify: boolean }) => void;
type OffEventListener = <E extends ClerkEvent>(event: E, handler: EventHandler<E>) => void;
Expand Down Expand Up @@ -1161,7 +1162,7 @@
phoneNumber?: string;
firstName?: string;
lastName?: string;
username?: string;

Check warning on line 1165 in packages/types/src/clerk.ts

View workflow job for this annotation

GitHub Actions / Static analysis

'unknown' overrides all other types in this union type
};

export type TasksRedirectOptions = RedirectOptions & RedirectUrlProp;
Expand Down
Loading