Skip to content

Commit 8e3947c

Browse files
panteliselefnikosdouvlis
authored andcommitted
feat(shared): React Query variant for useSubscription (#6913)
Co-authored-by: Nikos Douvlis <[email protected]>
1 parent 15c1e53 commit 8e3947c

25 files changed

+686
-87
lines changed

.changeset/tricky-badgers-post.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/shared': patch
4+
'@clerk/clerk-react': patch
5+
---
6+
7+
Experimental: Ground work for fixing stale data between hooks and components by sharing a single cache.

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/rspack.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@ const common = ({ mode, variant, disableRHC = false }) => {
117117
chunks: 'all',
118118
enforce: true,
119119
},
120+
queryCoreVendor: {
121+
test: /[\\/]node_modules[\\/](@tanstack\/query-core)[\\/]/,
122+
name: 'query-core-vendors',
123+
chunks: 'all',
124+
enforce: true,
125+
},
120126
/**
121127
* Sign up is shared between the SignUp component and the SignIn component.
122128
*/

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import type {
9494
} from '@clerk/shared/types';
9595
import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url';
9696
import { allSettled, handleValueOrFn, noop } from '@clerk/shared/utils';
97+
import type { QueryClient } from '@tanstack/query-core';
9798

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

@@ -219,6 +220,7 @@ export class Clerk implements ClerkInterface {
219220
// converted to protected environment to support `updateEnvironment` type assertion
220221
protected environment?: EnvironmentResource | null;
221222

223+
#queryClient: QueryClient | undefined;
222224
#publishableKey = '';
223225
#domain: DomainOrProxyUrl['domain'];
224226
#proxyUrl: DomainOrProxyUrl['proxyUrl'];
@@ -237,6 +239,28 @@ export class Clerk implements ClerkInterface {
237239
#touchThrottledUntil = 0;
238240
#publicEventBus = createClerkEventBus();
239241

242+
get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined {
243+
if (!this.#queryClient) {
244+
void import('./query-core')
245+
.then(module => module.QueryClient)
246+
.then(QueryClient => {
247+
if (this.#queryClient) {
248+
return;
249+
}
250+
this.#queryClient = new QueryClient();
251+
// @ts-expect-error - queryClientStatus is not typed
252+
this.#publicEventBus.emit('queryClientStatus', 'ready');
253+
});
254+
}
255+
256+
return this.#queryClient
257+
? {
258+
__tag: 'clerk-rq-client',
259+
client: this.#queryClient,
260+
}
261+
: undefined;
262+
}
263+
240264
public __internal_getCachedResources:
241265
| (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>)
242266
| 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/react/src/isomorphicClerk.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
146146
private premountApiKeysNodes = new Map<HTMLDivElement, APIKeysProps | undefined>();
147147
private premountOAuthConsentNodes = new Map<HTMLDivElement, __internal_OAuthConsentProps | undefined>();
148148
private premountTaskChooseOrganizationNodes = new Map<HTMLDivElement, TaskChooseOrganizationProps | undefined>();
149+
149150
// A separate Map of `addListener` method calls to handle multiple listeners.
150151
private premountAddListenerCalls = new Map<
151152
ListenerCallback,
@@ -283,6 +284,11 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
283284
return this.clerkjs?.isStandardBrowser || this.options.standardBrowser || false;
284285
}
285286

287+
get __internal_queryClient() {
288+
// @ts-expect-error - __internal_queryClient is not typed
289+
return this.clerkjs?.__internal_queryClient;
290+
}
291+
286292
get isSatellite() {
287293
// This getter can run in environments where window is not available.
288294
// In those cases we should expect and use domain as a string
@@ -567,6 +573,13 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
567573
this.on('status', listener, { notify: true });
568574
});
569575

576+
// @ts-expect-error - queryClientStatus is not typed
577+
this.#eventBus.internal.retrieveListeners('queryClientStatus')?.forEach(listener => {
578+
// Since clerkjs exists it will call `this.clerkjs.on('queryClientStatus', listener)`
579+
// @ts-expect-error - queryClientStatus is not typed
580+
this.on('queryClientStatus', listener, { notify: true });
581+
});
582+
570583
if (this.preopenSignIn !== null) {
571584
clerkjs.openSignIn(this.preopenSignIn);
572585
}

packages/shared/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@
163163
"devDependencies": {
164164
"@stripe/react-stripe-js": "3.1.1",
165165
"@stripe/stripe-js": "5.6.0",
166+
"@tanstack/query-core": "5.87.4",
166167
"@types/glob-to-regexp": "0.4.4",
167168
"@types/js-cookie": "3.0.6",
168169
"cross-fetch": "^4.1.0",
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type {
2+
DataTag,
3+
DefaultError,
4+
InitialDataFunction,
5+
NonUndefinedGuard,
6+
OmitKeyof,
7+
QueryFunction,
8+
QueryKey,
9+
SkipToken,
10+
} from '@tanstack/query-core';
11+
12+
import type { UseQueryOptions } from './types';
13+
14+
export type UndefinedInitialDataOptions<
15+
TQueryFnData = unknown,
16+
TError = DefaultError,
17+
TData = TQueryFnData,
18+
TQueryKey extends QueryKey = QueryKey,
19+
> = UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> & {
20+
initialData?: undefined | InitialDataFunction<NonUndefinedGuard<TQueryFnData>> | NonUndefinedGuard<TQueryFnData>;
21+
};
22+
23+
export type UnusedSkipTokenOptions<
24+
TQueryFnData = unknown,
25+
TError = DefaultError,
26+
TData = TQueryFnData,
27+
TQueryKey extends QueryKey = QueryKey,
28+
> = OmitKeyof<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>, 'queryFn'> & {
29+
queryFn?: Exclude<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>['queryFn'], SkipToken | undefined>;
30+
};
31+
32+
export type DefinedInitialDataOptions<
33+
TQueryFnData = unknown,
34+
TError = DefaultError,
35+
TData = TQueryFnData,
36+
TQueryKey extends QueryKey = QueryKey,
37+
> = Omit<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>, 'queryFn'> & {
38+
initialData: NonUndefinedGuard<TQueryFnData> | (() => NonUndefinedGuard<TQueryFnData>);
39+
queryFn?: QueryFunction<TQueryFnData, TQueryKey>;
40+
};
41+
42+
export function queryOptions<
43+
TQueryFnData = unknown,
44+
TError = DefaultError,
45+
TData = TQueryFnData,
46+
TQueryKey extends QueryKey = QueryKey,
47+
>(
48+
options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
49+
): DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
50+
queryKey: DataTag<TQueryKey, TQueryFnData, TError>;
51+
};
52+
53+
export function queryOptions<
54+
TQueryFnData = unknown,
55+
TError = DefaultError,
56+
TData = TQueryFnData,
57+
TQueryKey extends QueryKey = QueryKey,
58+
>(
59+
options: UnusedSkipTokenOptions<TQueryFnData, TError, TData, TQueryKey>,
60+
): UnusedSkipTokenOptions<TQueryFnData, TError, TData, TQueryKey> & {
61+
queryKey: DataTag<TQueryKey, TQueryFnData, TError>;
62+
};
63+
64+
export function queryOptions<
65+
TQueryFnData = unknown,
66+
TError = DefaultError,
67+
TData = TQueryFnData,
68+
TQueryKey extends QueryKey = QueryKey,
69+
>(
70+
options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
71+
): UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
72+
queryKey: DataTag<TQueryKey, TQueryFnData, TError>;
73+
};
74+
75+
/**
76+
*
77+
*/
78+
export function queryOptions(options: unknown) {
79+
return options;
80+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type {
2+
DefaultError,
3+
DefinedQueryObserverResult,
4+
InfiniteQueryObserverOptions,
5+
OmitKeyof,
6+
QueryKey,
7+
QueryObserverOptions,
8+
QueryObserverResult,
9+
} from '@tanstack/query-core';
10+
11+
export type AnyUseBaseQueryOptions = UseBaseQueryOptions<any, any, any, any, any>;
12+
export interface UseBaseQueryOptions<
13+
TQueryFnData = unknown,
14+
TError = DefaultError,
15+
TData = TQueryFnData,
16+
TQueryData = TQueryFnData,
17+
TQueryKey extends QueryKey = QueryKey,
18+
> extends QueryObserverOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey> {
19+
/**
20+
* Set this to `false` to unsubscribe this observer from updates to the query cache.
21+
* Defaults to `true`.
22+
*/
23+
subscribed?: boolean;
24+
}
25+
26+
export type AnyUseQueryOptions = UseQueryOptions<any, any, any, any>;
27+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
28+
export interface UseQueryOptions<
29+
TQueryFnData = unknown,
30+
TError = DefaultError,
31+
TData = TQueryFnData,
32+
TQueryKey extends QueryKey = QueryKey,
33+
> extends OmitKeyof<UseBaseQueryOptions<TQueryFnData, TError, TData, TQueryFnData, TQueryKey>, 'suspense'> {}
34+
35+
export type AnyUseInfiniteQueryOptions = UseInfiniteQueryOptions<any, any, any, any, any>;
36+
export interface UseInfiniteQueryOptions<
37+
TQueryFnData = unknown,
38+
TError = DefaultError,
39+
TData = TQueryFnData,
40+
TQueryKey extends QueryKey = QueryKey,
41+
TPageParam = unknown,
42+
> extends OmitKeyof<InfiniteQueryObserverOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>, 'suspense'> {
43+
/**
44+
* Set this to `false` to unsubscribe this observer from updates to the query cache.
45+
* Defaults to `true`.
46+
*/
47+
subscribed?: boolean;
48+
}
49+
50+
export type UseBaseQueryResult<TData = unknown, TError = DefaultError> = QueryObserverResult<TData, TError>;
51+
52+
export type UseQueryResult<TData = unknown, TError = DefaultError> = UseBaseQueryResult<TData, TError>;
53+
54+
export type DefinedUseQueryResult<TData = unknown, TError = DefaultError> = DefinedQueryObserverResult<TData, TError>;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { QueryClient } from '@tanstack/query-core';
2+
import { useEffect, useState } from 'react';
3+
4+
import { useClerkInstanceContext } from '../contexts';
5+
6+
export type RecursiveMock = {
7+
(...args: unknown[]): RecursiveMock;
8+
} & {
9+
readonly [key in string | symbol]: RecursiveMock;
10+
};
11+
12+
/**
13+
* Creates a recursively self-referential Proxy that safely handles:
14+
* - Arbitrary property access (e.g., obj.any.prop.path)
15+
* - Function calls at any level (e.g., obj.a().b.c())
16+
* - Construction (e.g., new obj.a.b())
17+
*
18+
* Always returns itself to allow infinite chaining without throwing.
19+
*/
20+
function createRecursiveProxy(label: string): RecursiveMock {
21+
// The callable target for the proxy so that `apply` works
22+
const callableTarget = function noop(): void {};
23+
24+
// eslint-disable-next-line prefer-const
25+
let self: RecursiveMock;
26+
const handler: ProxyHandler<typeof callableTarget> = {
27+
get(_target, prop) {
28+
// Avoid being treated as a Promise/thenable by test runners or frameworks
29+
if (prop === 'then') {
30+
return undefined;
31+
}
32+
if (prop === 'toString') {
33+
return () => `[${label}]`;
34+
}
35+
if (prop === Symbol.toPrimitive) {
36+
return () => 0;
37+
}
38+
return self;
39+
},
40+
apply() {
41+
return self;
42+
},
43+
construct() {
44+
return self as unknown as object;
45+
},
46+
has() {
47+
return false;
48+
},
49+
set() {
50+
return false;
51+
},
52+
};
53+
54+
self = new Proxy(callableTarget, handler) as unknown as RecursiveMock;
55+
return self;
56+
}
57+
58+
const mockQueryClient = createRecursiveProxy('ClerkMockQueryClient') as unknown as QueryClient;
59+
60+
const useClerkQueryClient = (): [QueryClient, boolean] => {
61+
const clerk = useClerkInstanceContext();
62+
63+
// @ts-expect-error - __internal_queryClient is not typed
64+
const queryClient = clerk.__internal_queryClient as { __tag: 'clerk-rq-client'; client: QueryClient } | undefined;
65+
const [, setQueryClientLoaded] = useState(
66+
typeof queryClient === 'object' && '__tag' in queryClient && queryClient.__tag === 'clerk-rq-client',
67+
);
68+
69+
useEffect(() => {
70+
const _setQueryClientLoaded = () => setQueryClientLoaded(true);
71+
// @ts-expect-error - queryClientStatus is not typed
72+
clerk.on('queryClientStatus', _setQueryClientLoaded);
73+
return () => {
74+
// @ts-expect-error - queryClientStatus is not typed
75+
clerk.off('queryClientStatus', _setQueryClientLoaded);
76+
};
77+
}, [clerk, setQueryClientLoaded]);
78+
79+
const isLoaded = typeof queryClient === 'object' && '__tag' in queryClient && queryClient.__tag === 'clerk-rq-client';
80+
81+
return [queryClient?.client || mockQueryClient, isLoaded];
82+
};
83+
84+
export { useClerkQueryClient };

0 commit comments

Comments
 (0)