-
Notifications
You must be signed in to change notification settings - Fork 386
fix(clerk-js, shared): Auto revalidate hook on mutations #6702
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'; | ||
|
@@ -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]); | ||
|
||
Comment on lines
+56
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix “null” uniqueKey leak into event registry
- const serializedKey = useMemo(() => JSON.stringify(key), [key]);
+ const serializedKey = useMemo(() => (key ? JSON.stringify(key) : undefined), [key]); 🤖 Prompt for AI Agents
|
||
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, | ||
|
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]); | ||
}; |
There was a problem hiding this comment.
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'
butorganization?.id
is not yet available, the hook still returns a non-null SWR key and callsgetSubscription({ orgId: undefined })
. Gate the key untilorganization.id
exists to avoid misrouted or failing requests.Apply:
📝 Committable suggestion
🤖 Prompt for AI Agents