-
Notifications
You must be signed in to change notification settings - Fork 391
feat(shared): React Query variant for useSubscription #6913
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 7 commits
a28fa4d
a3ad75c
e6d6561
82901ad
f5c27e3
827f536
96b68d2
c07fe17
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 |
---|---|---|
@@ -0,0 +1,7 @@ | ||
--- | ||
'@clerk/clerk-js': patch | ||
'@clerk/shared': patch | ||
'@clerk/clerk-react': patch | ||
--- | ||
|
||
wip |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -95,6 +95,7 @@ import type { | |
WaitlistResource, | ||
Web3Provider, | ||
} from '@clerk/types'; | ||
import type { QueryClient } from '@tanstack/query-core'; | ||
|
||
import { debugLogger, initDebugLogger } from '@/utils/debug'; | ||
|
||
|
@@ -222,6 +223,7 @@ export class Clerk implements ClerkInterface { | |
// converted to protected environment to support `updateEnvironment` type assertion | ||
protected environment?: EnvironmentResource | null; | ||
|
||
#queryClient: QueryClient | undefined; | ||
#publishableKey = ''; | ||
#domain: DomainOrProxyUrl['domain']; | ||
#proxyUrl: DomainOrProxyUrl['proxyUrl']; | ||
|
@@ -240,6 +242,28 @@ export class Clerk implements ClerkInterface { | |
#touchThrottledUntil = 0; | ||
#publicEventBus = createClerkEventBus(); | ||
|
||
get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined { | ||
if (!this.#queryClient) { | ||
void import('./query-core') | ||
.then(module => module.QueryClient) | ||
.then(QueryClient => { | ||
if (this.#queryClient) { | ||
return; | ||
} | ||
this.#queryClient = new QueryClient(); | ||
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. If we want revalidation on focus and the network status things to work, you will also need to call This is safe but a noop for native environments. |
||
// @ts-expect-error - queryClientStatus is not typed | ||
this.#publicEventBus.emit('queryClientStatus', 'ready'); | ||
}); | ||
} | ||
|
||
return this.#queryClient | ||
? { | ||
__tag: 'clerk-rq-client', // make this a symbol | ||
client: this.#queryClient, | ||
} | ||
: undefined; | ||
} | ||
|
||
public __internal_getCachedResources: | ||
| (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>) | ||
| undefined; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import { QueryClient } from '@tanstack/query-core'; | ||
|
||
export { QueryClient }; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -93,7 +93,10 @@ export function ClerkContextProvider(props: ClerkContextProvider) { | |
<IsomorphicClerkContext.Provider value={clerkCtx}> | ||
<ClientContext.Provider value={clientCtx}> | ||
<SessionContext.Provider value={sessionCtx}> | ||
<OrganizationProvider {...organizationCtx.value}> | ||
<OrganizationProvider | ||
// key={clerkStatus + queryStatus} | ||
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. NIT: ✂️ |
||
{...organizationCtx.value} | ||
> | ||
<AuthContext.Provider value={authCtx}> | ||
<UserContext.Provider value={userCtx}> | ||
<CheckoutProvider | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -146,6 +146,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { | |
private premountApiKeysNodes = new Map<HTMLDivElement, APIKeysProps | undefined>(); | ||
private premountOAuthConsentNodes = new Map<HTMLDivElement, __internal_OAuthConsentProps | undefined>(); | ||
private premountTaskChooseOrganizationNodes = new Map<HTMLDivElement, TaskChooseOrganizationProps | undefined>(); | ||
|
||
// A separate Map of `addListener` method calls to handle multiple listeners. | ||
private premountAddListenerCalls = new Map< | ||
ListenerCallback, | ||
|
@@ -283,6 +284,11 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { | |
return this.clerkjs?.isStandardBrowser || this.options.standardBrowser || false; | ||
} | ||
|
||
get __internal_queryClient() { | ||
// @ts-expect-error - __internal_queryClient is not typed | ||
return this.clerkjs?.__internal_queryClient; | ||
} | ||
panteliselef marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
get isSatellite() { | ||
// This getter can run in environments where window is not available. | ||
// In those cases we should expect and use domain as a string | ||
|
@@ -567,6 +573,13 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { | |
this.on('status', listener, { notify: true }); | ||
}); | ||
|
||
// @ts-expect-error - queryClientStatus is not typed | ||
this.#eventBus.internal.retrieveListeners('queryClientStatus')?.forEach(listener => { | ||
// Since clerkjs exists it will call `this.clerkjs.on('queryClientStatus', listener)` | ||
// @ts-expect-error - queryClientStatus is not typed | ||
this.on('queryClientStatus', listener, { notify: true }); | ||
}); | ||
Comment on lines
+576
to
+581
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. 🛠️ Refactor suggestion | 🟠 Major 🧩 Analysis chainEnsure The Verify that:
You can run this script to check if the event is documented elsewhere: 🏁 Script executed: #!/bin/bash
# Search for queryClientStatus event definitions and usages
rg -nC3 "queryClientStatus" --type=ts -g '!*.test.ts' Length of output: 148 🏁 Script executed: #!/bin/bash
rg -nC3 "queryClientStatus" Length of output: 2581 Define and type the Add
|
||
|
||
if (this.preopenSignIn !== null) { | ||
clerkjs.openSignIn(this.preopenSignIn); | ||
} | ||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -155,6 +155,7 @@ | |||||
"devDependencies": { | ||||||
"@stripe/react-stripe-js": "3.1.1", | ||||||
"@stripe/stripe-js": "5.6.0", | ||||||
"@tanstack/query-core": "5.87.4", | ||||||
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. Version mismatch with clerk-js package.
Apply this diff to use the workspace protocol for consistency: - "@tanstack/query-core": "5.87.4",
+ "@tanstack/query-core": "workspace:^", Alternatively, ensure both packages use the same exact version or caret range. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||
"@types/glob-to-regexp": "0.4.4", | ||||||
"@types/js-cookie": "3.0.6", | ||||||
"cross-fetch": "^4.1.0", | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import type { | ||
DataTag, | ||
DefaultError, | ||
InitialDataFunction, | ||
NonUndefinedGuard, | ||
OmitKeyof, | ||
QueryFunction, | ||
QueryKey, | ||
SkipToken, | ||
} from '@tanstack/query-core'; | ||
|
||
import type { UseQueryOptions } from './types'; | ||
|
||
export type UndefinedInitialDataOptions< | ||
TQueryFnData = unknown, | ||
TError = DefaultError, | ||
TData = TQueryFnData, | ||
TQueryKey extends QueryKey = QueryKey, | ||
> = UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> & { | ||
initialData?: undefined | InitialDataFunction<NonUndefinedGuard<TQueryFnData>> | NonUndefinedGuard<TQueryFnData>; | ||
}; | ||
|
||
export type UnusedSkipTokenOptions< | ||
TQueryFnData = unknown, | ||
TError = DefaultError, | ||
TData = TQueryFnData, | ||
TQueryKey extends QueryKey = QueryKey, | ||
> = OmitKeyof<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>, 'queryFn'> & { | ||
queryFn?: Exclude<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>['queryFn'], SkipToken | undefined>; | ||
}; | ||
|
||
export type DefinedInitialDataOptions< | ||
TQueryFnData = unknown, | ||
TError = DefaultError, | ||
TData = TQueryFnData, | ||
TQueryKey extends QueryKey = QueryKey, | ||
> = Omit<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>, 'queryFn'> & { | ||
initialData: NonUndefinedGuard<TQueryFnData> | (() => NonUndefinedGuard<TQueryFnData>); | ||
queryFn?: QueryFunction<TQueryFnData, TQueryKey>; | ||
}; | ||
|
||
export function queryOptions< | ||
TQueryFnData = unknown, | ||
TError = DefaultError, | ||
TData = TQueryFnData, | ||
TQueryKey extends QueryKey = QueryKey, | ||
>( | ||
options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>, | ||
): DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & { | ||
queryKey: DataTag<TQueryKey, TQueryFnData, TError>; | ||
}; | ||
|
||
export function queryOptions< | ||
TQueryFnData = unknown, | ||
TError = DefaultError, | ||
TData = TQueryFnData, | ||
TQueryKey extends QueryKey = QueryKey, | ||
>( | ||
options: UnusedSkipTokenOptions<TQueryFnData, TError, TData, TQueryKey>, | ||
): UnusedSkipTokenOptions<TQueryFnData, TError, TData, TQueryKey> & { | ||
queryKey: DataTag<TQueryKey, TQueryFnData, TError>; | ||
}; | ||
|
||
export function queryOptions< | ||
TQueryFnData = unknown, | ||
TError = DefaultError, | ||
TData = TQueryFnData, | ||
TQueryKey extends QueryKey = QueryKey, | ||
>( | ||
options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>, | ||
): UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & { | ||
queryKey: DataTag<TQueryKey, TQueryFnData, TError>; | ||
}; | ||
|
||
/** | ||
* | ||
*/ | ||
export function queryOptions(options: unknown) { | ||
return options; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import type { | ||
DefaultError, | ||
DefinedQueryObserverResult, | ||
InfiniteQueryObserverOptions, | ||
OmitKeyof, | ||
QueryKey, | ||
QueryObserverOptions, | ||
QueryObserverResult, | ||
} from '@tanstack/query-core'; | ||
|
||
export type AnyUseBaseQueryOptions = UseBaseQueryOptions<any, any, any, any, any>; | ||
export interface UseBaseQueryOptions< | ||
TQueryFnData = unknown, | ||
TError = DefaultError, | ||
TData = TQueryFnData, | ||
TQueryData = TQueryFnData, | ||
TQueryKey extends QueryKey = QueryKey, | ||
> extends QueryObserverOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey> { | ||
/** | ||
* Set this to `false` to unsubscribe this observer from updates to the query cache. | ||
* Defaults to `true`. | ||
*/ | ||
subscribed?: boolean; | ||
} | ||
|
||
export type AnyUseQueryOptions = UseQueryOptions<any, any, any, any>; | ||
export interface UseQueryOptions< | ||
TQueryFnData = unknown, | ||
TError = DefaultError, | ||
TData = TQueryFnData, | ||
TQueryKey extends QueryKey = QueryKey, | ||
> extends OmitKeyof<UseBaseQueryOptions<TQueryFnData, TError, TData, TQueryFnData, TQueryKey>, 'suspense'> {} | ||
|
||
export type AnyUseInfiniteQueryOptions = UseInfiniteQueryOptions<any, any, any, any, any>; | ||
export interface UseInfiniteQueryOptions< | ||
TQueryFnData = unknown, | ||
TError = DefaultError, | ||
TData = TQueryFnData, | ||
TQueryKey extends QueryKey = QueryKey, | ||
TPageParam = unknown, | ||
> extends OmitKeyof<InfiniteQueryObserverOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>, 'suspense'> { | ||
/** | ||
* Set this to `false` to unsubscribe this observer from updates to the query cache. | ||
* Defaults to `true`. | ||
*/ | ||
subscribed?: boolean; | ||
} | ||
|
||
export type UseBaseQueryResult<TData = unknown, TError = DefaultError> = QueryObserverResult<TData, TError>; | ||
|
||
export type UseQueryResult<TData = unknown, TError = DefaultError> = UseBaseQueryResult<TData, TError>; | ||
|
||
export type DefinedUseQueryResult<TData = unknown, TError = DefaultError> = DefinedQueryObserverResult<TData, TError>; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
import type { QueryClient } from '@tanstack/query-core'; | ||
import { useEffect, useState } from 'react'; | ||
|
||
import { useClerkInstanceContext } from '../contexts'; | ||
|
||
export type RecursiveMock = { | ||
(...args: unknown[]): RecursiveMock; | ||
} & { | ||
readonly [key in string | symbol]: RecursiveMock; | ||
}; | ||
|
||
/** | ||
* Creates a recursively self-referential Proxy that safely handles: | ||
* - Arbitrary property access (e.g., obj.any.prop.path) | ||
* - Function calls at any level (e.g., obj.a().b.c()) | ||
* - Construction (e.g., new obj.a.b()) | ||
* | ||
* Always returns itself to allow infinite chaining without throwing. | ||
*/ | ||
function createRecursiveProxy(label: string): RecursiveMock { | ||
// The callable target for the proxy so that `apply` works | ||
const callableTarget = function noop(): void {}; | ||
|
||
// eslint-disable-next-line prefer-const | ||
let self: RecursiveMock; | ||
const handler: ProxyHandler<typeof callableTarget> = { | ||
get(_target, prop) { | ||
// Avoid being treated as a Promise/thenable by test runners or frameworks | ||
if (prop === 'then') { | ||
return undefined; | ||
} | ||
if (prop === 'toString') { | ||
return () => `[${label}]`; | ||
} | ||
if (prop === Symbol.toPrimitive) { | ||
return () => 0; | ||
} | ||
return self; | ||
}, | ||
apply() { | ||
return self; | ||
}, | ||
construct() { | ||
return self as unknown as object; | ||
}, | ||
has() { | ||
return false; | ||
}, | ||
set() { | ||
return false; | ||
}, | ||
}; | ||
|
||
self = new Proxy(callableTarget, handler) as unknown as RecursiveMock; | ||
return self; | ||
} | ||
|
||
const mockQueryClient = createRecursiveProxy('ClerkMockQueryClient') as unknown as QueryClient; | ||
|
||
const useClerkQueryClient = (): QueryClient => { | ||
const clerk = useClerkInstanceContext(); | ||
|
||
// @ts-expect-error - __internal_queryClient is not typed | ||
const queryClient = clerk.__internal_queryClient as { __tag: 'clerk-rq-client'; client: QueryClient } | undefined; | ||
const [, setQueryClientLoaded] = useState( | ||
typeof queryClient === 'object' && '__tag' in queryClient && queryClient.__tag === 'clerk-rq-client', | ||
); | ||
|
||
useEffect(() => { | ||
const _setQueryClientLoaded = () => setQueryClientLoaded(true); | ||
// @ts-expect-error - queryClientStatus is not typed | ||
clerk.on('queryClientStatus', _setQueryClientLoaded); | ||
return () => { | ||
// @ts-expect-error - queryClientStatus is not typed | ||
clerk.off('queryClientStatus', _setQueryClientLoaded); | ||
}; | ||
}, [clerk, setQueryClientLoaded]); | ||
|
||
return queryClient?.client || mockQueryClient; | ||
}; | ||
|
||
export { useClerkQueryClient }; |
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.
I think there are options we might want to consider here, like retry logic. Do we want to always retry all errors, or ignore retrying 4xx kind of errors for example? How many retries should we do by default? Is the default retry delay good? Do we want to match these closely to what we have in SWR today, or change them as part of this?
To be clear, the default RQ ones does the job so I don't think we need to, especially at this early stage, just wanted to mention it.