Skip to content

Commit a28fa4d

Browse files
committed
feat(clerk-js): Lazy query client
1 parent 1c04a32 commit a28fa4d

File tree

9 files changed

+207
-12
lines changed

9 files changed

+207
-12
lines changed

packages/clerk-js/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"@formkit/auto-animate": "^0.8.2",
7272
"@stripe/stripe-js": "5.6.0",
7373
"@swc/helpers": "^0.5.17",
74+
"@tanstack/query-core": "^5.87.4",
7475
"@zxcvbn-ts/core": "3.0.4",
7576
"@zxcvbn-ts/language-common": "3.0.4",
7677
"alien-signals": "2.0.6",

packages/clerk-js/src/core/clerk.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import type {
9595
WaitlistResource,
9696
Web3Provider,
9797
} from '@clerk/types';
98+
import type { QueryClient } from '@tanstack/query-core';
9899

99100
import { debugLogger, initDebugLogger } from '@/utils/debug';
100101

@@ -222,6 +223,7 @@ export class Clerk implements ClerkInterface {
222223
// converted to protected environment to support `updateEnvironment` type assertion
223224
protected environment?: EnvironmentResource | null;
224225

226+
#queryClient: QueryClient | undefined;
225227
#publishableKey = '';
226228
#domain: DomainOrProxyUrl['domain'];
227229
#proxyUrl: DomainOrProxyUrl['proxyUrl'];
@@ -240,6 +242,20 @@ export class Clerk implements ClerkInterface {
240242
#touchThrottledUntil = 0;
241243
#publicEventBus = createClerkEventBus();
242244

245+
get __internal_queryClient(): QueryClient | undefined {
246+
return this.#queryClient;
247+
}
248+
249+
public async getInternalQueryClient(): Promise<QueryClient> {
250+
const QueryClient = await import('./query-core').then(module => module.QueryClient);
251+
if (!this.#queryClient) {
252+
this.#queryClient = new QueryClient();
253+
// @ts-expect-error - queryClientStatus is not typed
254+
this.#publicEventBus.emit('queryClientStatus', 'ready');
255+
}
256+
return this.#queryClient;
257+
}
258+
243259
public __internal_getCachedResources:
244260
| (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>)
245261
| undefined;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { QueryClient } from '@tanstack/query-core';
2+
3+
export { QueryClient };

packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export function CoreClerkContextWrapper(props: CoreClerkContextWrapperProps): JS
5252
<ClientContext.Provider value={clientCtx}>
5353
<SessionContext.Provider value={sessionCtx}>
5454
<OrganizationProvider
55+
// @ts-expect-error - __internal_queryClient is not typed
56+
queryClient={clerk.__internal_queryClient}
5557
{...organizationCtx.value}
5658
swrConfig={props.swrConfig}
5759
>

packages/react/src/contexts/ClerkContextProvider.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,33 @@ export function ClerkContextProvider(props: ClerkContextProvider) {
8888
return { value };
8989
}, [orgId, organization]);
9090

91+
const [queryStatus, setQueryStatus] = React.useState('loading');
92+
93+
React.useEffect(() => {
94+
// @ts-expect-error - queryClientStatus is not typed
95+
clerk.on('queryClientStatus', setQueryStatus);
96+
return () => {
97+
// @ts-expect-error - queryClientStatus is not typed
98+
clerk.off('queryClientStatus', setQueryStatus);
99+
};
100+
}, [clerk]);
101+
102+
const queryClient = React.useMemo(() => {
103+
return clerk.__internal_queryClient;
104+
}, [queryStatus, clerkStatus]);
105+
106+
console.log('queryStatus', queryStatus, queryClient);
107+
91108
return (
92109
// @ts-expect-error value passed is of type IsomorphicClerk where the context expects LoadedClerk
93110
<IsomorphicClerkContext.Provider value={clerkCtx}>
94111
<ClientContext.Provider value={clientCtx}>
95112
<SessionContext.Provider value={sessionCtx}>
96-
<OrganizationProvider {...organizationCtx.value}>
113+
<OrganizationProvider
114+
key={clerkStatus + queryStatus}
115+
{...organizationCtx.value}
116+
queryClient={queryClient}
117+
>
97118
<AuthContext.Provider value={authCtx}>
98119
<UserContext.Provider value={userCtx}>
99120
<CheckoutProvider

packages/react/src/isomorphicClerk.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,66 @@ type IsomorphicLoadedClerk = Without<
115115
apiKeys: APIKeysNamespace | undefined;
116116
};
117117

118+
export type RecursiveMock = {
119+
(...args: unknown[]): RecursiveMock;
120+
} & {
121+
readonly [key in string | symbol]: RecursiveMock;
122+
};
123+
124+
/**
125+
* Creates a recursively self-referential Proxy that safely handles:
126+
* - Arbitrary property access (e.g., obj.any.prop.path)
127+
* - Function calls at any level (e.g., obj.a().b.c())
128+
* - Construction (e.g., new obj.a.b())
129+
*
130+
* Always returns itself to allow infinite chaining without throwing.
131+
*/
132+
function createRecursiveProxy(label: string = 'Mock'): RecursiveMock {
133+
// The callable target for the proxy so that `apply` works
134+
const callableTarget = function noop(): void {};
135+
136+
// eslint-disable-next-line prefer-const
137+
let self: RecursiveMock;
138+
const handler: ProxyHandler<typeof callableTarget> = {
139+
get(_target, prop) {
140+
// Avoid being treated as a Promise/thenable by test runners or frameworks
141+
if (prop === 'then') {
142+
return undefined;
143+
}
144+
if (prop === 'toString') {
145+
return () => `[${label}]`;
146+
}
147+
if (prop === Symbol.toPrimitive) {
148+
return () => 0;
149+
}
150+
return self;
151+
},
152+
apply() {
153+
return self;
154+
},
155+
construct() {
156+
return self as unknown as object;
157+
},
158+
has() {
159+
return true;
160+
},
161+
set() {
162+
return true;
163+
},
164+
};
165+
166+
self = new Proxy(callableTarget, handler) as unknown as RecursiveMock;
167+
return self;
168+
}
169+
170+
/**
171+
* Returns a permissive mock compatible with `QueryClient` usage in tests.
172+
* It accepts any chain of property accesses and calls without throwing.
173+
*/
174+
export function createMockQueryClient(): RecursiveMock {
175+
return createRecursiveProxy('MockQueryClient') as unknown as RecursiveMock;
176+
}
177+
118178
export class IsomorphicClerk implements IsomorphicLoadedClerk {
119179
private readonly mode: 'browser' | 'server';
120180
private readonly options: IsomorphicClerkOptions;
@@ -146,6 +206,8 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
146206
private premountApiKeysNodes = new Map<HTMLDivElement, APIKeysProps | undefined>();
147207
private premountOAuthConsentNodes = new Map<HTMLDivElement, __internal_OAuthConsentProps | undefined>();
148208
private premountTaskChooseOrganizationNodes = new Map<HTMLDivElement, TaskChooseOrganizationProps | undefined>();
209+
private prefetchQueryClientStatus = false;
210+
149211
// A separate Map of `addListener` method calls to handle multiple listeners.
150212
private premountAddListenerCalls = new Map<
151213
ListenerCallback,
@@ -162,6 +224,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
162224
#publishableKey: string;
163225
#eventBus = createClerkEventBus();
164226
#stateProxy: StateProxy;
227+
#__internal_queryClient = createMockQueryClient();
165228

166229
get publishableKey(): string {
167230
return this.#publishableKey;
@@ -283,6 +346,18 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
283346
return this.clerkjs?.isStandardBrowser || this.options.standardBrowser || false;
284347
}
285348

349+
get __internal_queryClient() {
350+
// @ts-expect-error - __internal_queryClient is not typed
351+
if (!this.clerkjs?.__internal_queryClient) {
352+
// @ts-expect-error - __internal_queryClient is not typed
353+
void this.clerkjs?.getInternalQueryClient?.();
354+
this.prefetchQueryClientStatus = true;
355+
}
356+
357+
// @ts-expect-error - __internal_queryClient is not typed
358+
return this.clerkjs?.__internal_queryClient || this.#__internal_queryClient;
359+
}
360+
286361
get isSatellite() {
287362
// This getter can run in environments where window is not available.
288363
// In those cases we should expect and use domain as a string
@@ -567,6 +642,13 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
567642
this.on('status', listener, { notify: true });
568643
});
569644

645+
// @ts-expect-error - queryClientStatus is not typed
646+
this.#eventBus.internal.retrieveListeners('queryClientStatus')?.forEach(listener => {
647+
// Since clerkjs exists it will call `this.clerkjs.on('status', listener)`
648+
// @ts-expect-error - queryClientStatus is not typed
649+
this.on('queryClientStatus', listener, { notify: true });
650+
});
651+
570652
if (this.preopenSignIn !== null) {
571653
clerkjs.openSignIn(this.preopenSignIn);
572654
}
@@ -611,6 +693,11 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
611693
clerkjs.openWaitlist(this.preOpenWaitlist);
612694
}
613695

696+
if (this.prefetchQueryClientStatus) {
697+
// @ts-expect-error - queryClientStatus is not typed
698+
this.clerkjs.getInternalQueryClient?.();
699+
}
700+
614701
this.premountSignInNodes.forEach((props, node) => {
615702
clerkjs.mountSignIn(node, props);
616703
});

packages/shared/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@
146146
},
147147
"dependencies": {
148148
"@clerk/types": "workspace:^",
149+
"@tanstack/react-query": "^5.87.4",
149150
"dequal": "2.0.3",
150151
"glob-to-regexp": "0.4.1",
151152
"js-cookie": "3.0.5",

packages/shared/src/react/contexts.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
SignedInSessionResource,
1111
UserResource,
1212
} from '@clerk/types';
13+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
1314
import type { PropsWithChildren } from 'react';
1415
import React from 'react';
1516

@@ -59,22 +60,27 @@ const OrganizationProvider = ({
5960
children,
6061
organization,
6162
swrConfig,
63+
queryClient,
6264
}: PropsWithChildren<
6365
OrganizationContextProps & {
6466
// Exporting inferred types directly from SWR will result in error while building declarations
6567
swrConfig?: any;
68+
queryClient?: QueryClient;
6669
}
6770
>) => {
71+
const [defaultClient] = React.useState(() => new QueryClient());
6872
return (
69-
<SWRConfig value={swrConfig}>
70-
<OrganizationContextInternal.Provider
71-
value={{
72-
value: { organization },
73-
}}
74-
>
75-
{children}
76-
</OrganizationContextInternal.Provider>
77-
</SWRConfig>
73+
<QueryClientProvider client={queryClient || defaultClient}>
74+
<SWRConfig value={swrConfig}>
75+
<OrganizationContextInternal.Provider
76+
value={{
77+
value: { organization },
78+
}}
79+
>
80+
{children}
81+
</OrganizationContextInternal.Provider>
82+
</SWRConfig>
83+
</QueryClientProvider>
7884
);
7985
};
8086

packages/shared/src/react/hooks/useSubscription.tsx

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { EnvironmentResource, ForPayerType } from '@clerk/types';
2-
import { useCallback } from 'react';
2+
import { useQuery, useQueryClient } from '@tanstack/react-query';
3+
import { useCallback, useMemo } from 'react';
34

45
import { eventMethodCalled } from '../../telemetry/events';
56
import { useSWR } from '../clerk-swr';
@@ -29,7 +30,7 @@ type UseSubscriptionParams = {
2930
*
3031
* @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes.
3132
*/
32-
export const useSubscription = (params?: UseSubscriptionParams) => {
33+
export const useSubscriptionPrev = (params?: UseSubscriptionParams) => {
3334
useAssertWrappedByClerkProvider(hookName);
3435

3536
const clerk = useClerkInstanceContext();
@@ -78,3 +79,60 @@ export const useSubscription = (params?: UseSubscriptionParams) => {
7879
revalidate,
7980
};
8081
};
82+
83+
export const useSubscription = (params?: UseSubscriptionParams) => {
84+
useAssertWrappedByClerkProvider(hookName);
85+
86+
const clerk = useClerkInstanceContext();
87+
const user = useUserContext();
88+
const { organization } = useOrganizationContext();
89+
90+
// console.log('cache', cache);
91+
92+
// @ts-expect-error `__unstable__environment` is not typed
93+
const environment = clerk.__unstable__environment as unknown as EnvironmentResource | null | undefined;
94+
95+
clerk.telemetry?.record(eventMethodCalled(hookName));
96+
97+
const isOrganization = params?.for === 'organization';
98+
const billingEnabled = isOrganization
99+
? environment?.commerceSettings.billing.organization.enabled
100+
: environment?.commerceSettings.billing.user.enabled;
101+
102+
const queryClient = useQueryClient();
103+
104+
const queryKey = useMemo(() => {
105+
return [
106+
'commerce-subscription',
107+
{
108+
userId: user?.id,
109+
args: { orgId: isOrganization ? organization?.id : undefined },
110+
},
111+
];
112+
}, [user?.id, isOrganization, organization?.id]);
113+
114+
const query = useQuery({
115+
queryKey,
116+
queryFn: ({ queryKey }) => {
117+
const obj = queryKey[1] as {
118+
args: {
119+
orgId?: string;
120+
};
121+
};
122+
return clerk.billing.getSubscription(obj.args);
123+
},
124+
staleTime: 1_0000 * 60,
125+
enabled: Boolean(user?.id && billingEnabled) && clerk.status === 'ready',
126+
// placeholderData
127+
});
128+
129+
const revalidate = useCallback(() => queryClient.invalidateQueries({ queryKey }), [queryClient, queryKey]);
130+
131+
return {
132+
data: query.data,
133+
error: query.error,
134+
isLoading: query.isLoading,
135+
isFetching: query.isFetching,
136+
revalidate,
137+
};
138+
};

0 commit comments

Comments
 (0)