diff --git a/.changeset/invert-clerk-rq-ownership.md b/.changeset/invert-clerk-rq-ownership.md new file mode 100644 index 00000000000..bac01150ab0 --- /dev/null +++ b/.changeset/invert-clerk-rq-ownership.md @@ -0,0 +1,11 @@ +--- +'@clerk/shared': patch +'@clerk/clerk-js': patch +'@clerk/react': patch +--- + +Move ownership of the clerk-rq `QueryClient` from `@clerk/clerk-js` into `@clerk/shared`. The `QueryObserver` (constructed in `@clerk/shared`) and the `Query` objects it observes now always come from a single `@tanstack/query-core` resolution — the cross-bundle API contract that produced #8428 (`Query.isFetched is not a function`) no longer exists. + +This removes the undocumented `clerk.__internal_queryClient` getter from both `@clerk/clerk-js` and `@clerk/react`'s `IsomorphicClerk`. The `QueryClient` is owned by an internal singleton in `@clerk/shared`, lazily instantiated on the browser only — server renders return `undefined`, preserving SSR safety and avoiding cross-request cache sharing. + +`@tanstack/query-core` is no longer a direct dependency of `@clerk/clerk-js`; it remains a dep of `@clerk/shared` and resolves consumer-side as before. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 3aef7fb1570..d2e6db5a34d 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -9,7 +9,6 @@ { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, { "path": "./dist/base-account-sdk*.js", "maxSize": "203KB" }, { "path": "./dist/stripe-vendors*.js", "maxSize": "1KB" }, - { "path": "./dist/query-core-vendors*.js", "maxSize": "11KB" }, { "path": "./dist/zxcvbn-ts-core*.js", "maxSize": "12KB" }, { "path": "./dist/zxcvbn-common*.js", "maxSize": "226KB" } ] diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 7e8db416c0e..63fb8b30fe9 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -91,7 +91,6 @@ "@solana/wallet-standard": "catalog:module-manager", "@stripe/stripe-js": "5.6.0", "@swc/helpers": "catalog:repo", - "@tanstack/query-core": "catalog:repo", "@wallet-standard/core": "catalog:module-manager", "@zxcvbn-ts/core": "catalog:module-manager", "@zxcvbn-ts/language-common": "catalog:module-manager", diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index d0d50fc816e..ba99183e24a 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -110,12 +110,6 @@ const common = ({ mode, variant, disableRHC = false }) => { chunks: 'all', enforce: true, }, - queryCoreVendor: { - test: /[\\/]node_modules[\\/](@tanstack\/query-core)[\\/]/, - name: 'query-core-vendors', - chunks: 'all', - enforce: true, - }, defaultVendors: { minChunks: 1, test: module => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 1283fae8960..ac66ed01948 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -138,7 +138,6 @@ import type { import type { ClerkUI } from '@clerk/shared/ui'; import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url'; import { allSettled, handleValueOrFn, noop } from '@clerk/shared/utils'; -import type { QueryClient } from '@tanstack/query-core'; import { debugLogger, initDebugLogger } from '@/utils/debug'; import { ModuleManager } from '@/utils/moduleManager'; @@ -248,7 +247,6 @@ 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']; @@ -268,28 +266,6 @@ 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(); - // @ts-expect-error - queryClientStatus is not typed - this.#publicEventBus.emit('queryClientStatus', 'ready'); - }); - } - - return this.#queryClient - ? { - __tag: 'clerk-rq-client', - client: this.#queryClient, - } - : undefined; - } - public __internal_getCachedResources: | (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>) | undefined; diff --git a/packages/clerk-js/src/core/query-core.ts b/packages/clerk-js/src/core/query-core.ts deleted file mode 100644 index 71a5e77cc2d..00000000000 --- a/packages/clerk-js/src/core/query-core.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { QueryClient } from '@tanstack/query-core'; - -export { QueryClient }; diff --git a/packages/clerk-js/src/test/mock-helpers.ts b/packages/clerk-js/src/test/mock-helpers.ts index d76dea115bb..496f2b629ac 100644 --- a/packages/clerk-js/src/test/mock-helpers.ts +++ b/packages/clerk-js/src/test/mock-helpers.ts @@ -1,7 +1,7 @@ +import { __createClerkTestQueryClient } from '@clerk/shared/react'; import type { ActiveSessionResource, LoadedClerk } from '@clerk/shared/types'; import { type Mocked, vi } from 'vitest'; -import { QueryClient } from '../core/query-core'; import type { RouteContextValue } from '../ui/router'; type FunctionLike = (...args: any) => any; @@ -46,19 +46,7 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepVitestMocked defaultQueryClient), - configurable: true, - }); - mockProp(clerkAny, 'navigate'); mockProp(clerkAny, 'setActive'); mockProp(clerkAny, 'redirectWithAuth'); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index b47acb36f15..e8fbe7530ee 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -344,11 +344,6 @@ 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; - } - 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 @@ -656,13 +651,6 @@ 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 }); - }); - if (this.preopenSignIn !== null) { clerkjs.openSignIn(this.preopenSignIn); } diff --git a/packages/shared/src/react/clerk-rq/__tests__/clerk-query-client.spec.ts b/packages/shared/src/react/clerk-rq/__tests__/clerk-query-client.spec.ts new file mode 100644 index 00000000000..0d3e8eba0ad --- /dev/null +++ b/packages/shared/src/react/clerk-rq/__tests__/clerk-query-client.spec.ts @@ -0,0 +1,84 @@ +import { QueryClient } from '@tanstack/query-core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + __createClerkTestQueryClient, + __resetClerkQueryClientForTest, + __setClerkQueryClientForTest, + getClerkQueryClient, +} from '../clerk-query-client'; + +afterEach(() => { + vi.unstubAllGlobals(); + __resetClerkQueryClientForTest(); +}); + +describe('getClerkQueryClient', () => { + it('returns undefined when window is not defined (SSR)', () => { + vi.stubGlobal('window', undefined); + + expect(getClerkQueryClient()).toBeUndefined(); + }); + + it('does not cache the SSR undefined — a later browser call still creates a client', () => { + vi.stubGlobal('window', undefined); + expect(getClerkQueryClient()).toBeUndefined(); + + vi.unstubAllGlobals(); + const client = getClerkQueryClient(); + expect(client).toBeInstanceOf(QueryClient); + }); + + it('lazy-creates a singleton on the browser and returns the same instance on repeated calls', () => { + const first = getClerkQueryClient(); + const second = getClerkQueryClient(); + + expect(first).toBeInstanceOf(QueryClient); + expect(second).toBe(first); + }); +}); + +describe('__resetClerkQueryClientForTest', () => { + it('clears the singleton so the next read lazy-creates a fresh client', () => { + const original = getClerkQueryClient(); + expect(original).toBeInstanceOf(QueryClient); + + __resetClerkQueryClientForTest(); + + const next = getClerkQueryClient(); + expect(next).toBeInstanceOf(QueryClient); + expect(next).not.toBe(original); + }); +}); + +describe('__setClerkQueryClientForTest', () => { + it('installs a caller-supplied client and returns it from getClerkQueryClient', () => { + const custom = new QueryClient(); + __setClerkQueryClientForTest(custom); + + expect(getClerkQueryClient()).toBe(custom); + }); + + it('installs the "no client" state without triggering lazy creation on subsequent reads', () => { + __setClerkQueryClientForTest(undefined); + + expect(getClerkQueryClient()).toBeUndefined(); + expect(getClerkQueryClient()).toBeUndefined(); + }); +}); + +describe('__createClerkTestQueryClient', () => { + it('returns a QueryClient with deterministic defaults and installs it as the singleton', () => { + const client = __createClerkTestQueryClient(); + + expect(client).toBeInstanceOf(QueryClient); + expect(getClerkQueryClient()).toBe(client); + + const defaults = client.getDefaultOptions().queries; + expect(defaults?.retry).toBe(false); + expect(defaults?.staleTime).toBe(Infinity); + expect(defaults?.refetchOnWindowFocus).toBe(false); + expect(defaults?.refetchOnReconnect).toBe(false); + expect(defaults?.refetchOnMount).toBe(false); + }); +}); diff --git a/packages/shared/src/react/clerk-rq/__tests__/useBaseQuery.spec.tsx b/packages/shared/src/react/clerk-rq/__tests__/useBaseQuery.spec.tsx index 0172ab99f30..b658b8eb7bd 100644 --- a/packages/shared/src/react/clerk-rq/__tests__/useBaseQuery.spec.tsx +++ b/packages/shared/src/react/clerk-rq/__tests__/useBaseQuery.spec.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockClerk, createMockQueryClient } from '../../hooks/__tests__/mocks/clerk'; +import { __resetClerkQueryClientForTest, __setClerkQueryClientForTest } from '../clerk-query-client'; import { useClerkInfiniteQuery } from '../useInfiniteQuery'; import { useClerkQuery } from '../useQuery'; @@ -16,22 +17,15 @@ vi.mock('../../contexts', () => ({ const wrapper = ({ children }: { children: React.ReactNode }) => <>{children}; -const makeClerkWithoutQueryClient = () => { - const mockClerk = createMockClerk({ queryClient: null }); - Object.defineProperty(mockClerk, '__internal_queryClient', { - get: () => undefined, - configurable: true, - }); - return mockClerk; -}; - afterEach(() => { vi.clearAllMocks(); + __resetClerkQueryClientForTest(); }); describe('useBaseQuery - dummy result while query client is not attached', () => { beforeEach(() => { - activeClerk = makeClerkWithoutQueryClient(); + activeClerk = createMockClerk({ queryClient: null }); + __setClerkQueryClientForTest(undefined); }); it('reports isLoading: true when the query would be enabled', () => { @@ -109,8 +103,8 @@ describe('useBaseQuery - dummy result while query client is not attached', () => describe('useBaseQuery - normal behavior once query client attaches', () => { it('delegates to the real observer when the query client is loaded', async () => { - const queryClient = createMockQueryClient(); - activeClerk = createMockClerk({ queryClient }); + createMockQueryClient(); + activeClerk = createMockClerk({ queryClient: undefined }); const queryFn = vi.fn(async () => 'result'); const { result } = renderHook( diff --git a/packages/shared/src/react/clerk-rq/clerk-query-client.ts b/packages/shared/src/react/clerk-rq/clerk-query-client.ts new file mode 100644 index 00000000000..0648711868a --- /dev/null +++ b/packages/shared/src/react/clerk-rq/clerk-query-client.ts @@ -0,0 +1,66 @@ +import { QueryClient } from '@tanstack/query-core'; + +/** + * The QueryClient backing every clerk-rq hook. Owned by `@clerk/shared` so the + * `QueryObserver` that observes it and the `Query` objects inside it always + * resolve to the same `@tanstack/query-core` (no cross-bundle drift between + * the consumer-side `@clerk/shared` and the production CDN `clerk-js` bundle). + * + * Lazily instantiated on the client only. Server-side renders return + * `undefined` so per-request renders never share a cache across requests. + */ +let clerkQueryClient: QueryClient | undefined; +let initialized = false; + +export function getClerkQueryClient(): QueryClient | undefined { + if (typeof window === 'undefined') { + return undefined; + } + if (!initialized) { + clerkQueryClient = new QueryClient(); + initialized = true; + } + return clerkQueryClient; +} + +/** + * Test-only: install a custom client (for deterministic defaults like + * `staleTime: Infinity`) or pass `undefined` to simulate the "no client" + * state without triggering lazy creation on subsequent reads. + */ +export function __setClerkQueryClientForTest(client: QueryClient | undefined): void { + clerkQueryClient = client; + initialized = true; +} + +/** + * Test-only: build and install a fresh `QueryClient` with deterministic + * defaults (no retries, infinite stale time, no refetching). Returns the + * client so the spec can read/write its cache directly. + * + * Avoids forcing every test consumer to depend on `@tanstack/query-core`. + */ +export function __createClerkTestQueryClient(): QueryClient { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }, + }, + }); + __setClerkQueryClientForTest(client); + return client; +} + +/** + * Test-only: clear both the override and the initialization flag so the + * next read lazy-creates a fresh client. + */ +export function __resetClerkQueryClientForTest(): void { + clerkQueryClient = undefined; + initialized = false; +} diff --git a/packages/shared/src/react/clerk-rq/use-clerk-query-client.ts b/packages/shared/src/react/clerk-rq/use-clerk-query-client.ts index 1875742240b..ede35b0a8f0 100644 --- a/packages/shared/src/react/clerk-rq/use-clerk-query-client.ts +++ b/packages/shared/src/react/clerk-rq/use-clerk-query-client.ts @@ -1,7 +1,6 @@ import type { QueryClient } from '@tanstack/query-core'; -import { useEffect, useState } from 'react'; -import { useClerkInstanceContext } from '../contexts'; +import { getClerkQueryClient } from './clerk-query-client'; export type RecursiveMock = { (...args: unknown[]): RecursiveMock; @@ -57,28 +56,14 @@ function createRecursiveProxy(label: string): RecursiveMock { const mockQueryClient = createRecursiveProxy('ClerkMockQueryClient') as unknown as QueryClient; +/** + * Returns `[client, isLoaded]`. The real client is owned by `@clerk/shared` + * and lazily instantiated on the browser only — SSR returns the proxy mock + * + `isLoaded: false` so per-request renders never share a query cache. + */ const useClerkQueryClient = (): [QueryClient, boolean] => { - 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]); - - const isLoaded = typeof queryClient === 'object' && '__tag' in queryClient && queryClient.__tag === 'clerk-rq-client'; - - return [queryClient?.client || mockQueryClient, isLoaded]; + const client = getClerkQueryClient(); + return [client ?? mockQueryClient, Boolean(client)]; }; export { useClerkQueryClient }; diff --git a/packages/shared/src/react/hooks/__tests__/mocks/clerk.ts b/packages/shared/src/react/hooks/__tests__/mocks/clerk.ts index cf1a5c88c2e..fb03bc40cab 100644 --- a/packages/shared/src/react/hooks/__tests__/mocks/clerk.ts +++ b/packages/shared/src/react/hooks/__tests__/mocks/clerk.ts @@ -1,33 +1,49 @@ import { QueryClient } from '@tanstack/query-core'; import { vi } from 'vitest'; +import { __setClerkQueryClientForTest } from '@/react/clerk-rq/clerk-query-client'; + /** - * Shared query client configuration for tests + * Builds a deterministic QueryClient and installs it as the shared singleton. + * Returns the legacy `{__tag, client}` shape so existing specs that read + * `.client.setQueryData(...)` keep working without churn. */ export function createMockQueryClient() { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }, + }, + }); + __setClerkQueryClientForTest(client); return { __tag: 'clerk-rq-client' as const, - client: new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: Infinity, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - }, - }, - }), + client, }; } /** - * Simple mock Clerk factory with common properties + * Simple mock Clerk factory with common properties. The clerk-rq query client + * is no longer attached to the Clerk instance — pass `queryClient: null` to + * reset the shared singleton, or omit the option to install a fresh default. */ export function createMockClerk(overrides: any = {}) { - const queryClient = overrides.queryClient || createMockQueryClient(); + if (overrides.queryClient === null) { + __setClerkQueryClientForTest(undefined); + } else if (overrides.queryClient === undefined) { + createMockQueryClient(); + } + // Caller-supplied queryClient (the {__tag, client} wrapper) is already + // installed by createMockQueryClient at the test's top-level — nothing to do. - const mockClerk: any = { + const { queryClient: _ignored, ...rest } = overrides; + + return { loaded: true, telemetry: { record: vi.fn() }, on: vi.fn(), @@ -47,18 +63,8 @@ export function createMockClerk(overrides: any = {}) { }, }, }, - ...overrides, + ...rest, }; - - // Add query client as getter if not already set - if (!Object.getOwnPropertyDescriptor(mockClerk, '__internal_queryClient')) { - Object.defineProperty(mockClerk, '__internal_queryClient', { - get: vi.fn(() => queryClient), - configurable: true, - }); - } - - return mockClerk; } export function createMockUser(overrides: any = {}) { diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index bdc007dc0f8..01e2ce9b3ba 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -18,3 +18,10 @@ export { ClerkContextProvider } from './ClerkContextProvider'; export * from './billing/payment-element'; export { UNSAFE_PortalProvider, usePortalRoot } from './PortalProvider'; + +export { + __createClerkTestQueryClient, + __resetClerkQueryClientForTest, + __setClerkQueryClientForTest, + getClerkQueryClient, +} from './clerk-rq/clerk-query-client'; diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index ef2cfab0ea3..fe02ae1b6d5 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ - { "path": "./dist/ui.browser.js", "maxSize": "36KB" }, - { "path": "./dist/ui.legacy.browser.js", "maxSize": "76KB" }, + { "path": "./dist/ui.browser.js", "maxSize": "42KB" }, + { "path": "./dist/ui.legacy.browser.js", "maxSize": "84KB" }, { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, { "path": "./dist/ui-common*.js", "maxSize": "130KB" }, diff --git a/packages/ui/src/test/mock-helpers.ts b/packages/ui/src/test/mock-helpers.ts index 35bcb54a8f6..0f3ca058649 100644 --- a/packages/ui/src/test/mock-helpers.ts +++ b/packages/ui/src/test/mock-helpers.ts @@ -1,7 +1,7 @@ +import { __createClerkTestQueryClient } from '@clerk/shared/react'; import type { ActiveSessionResource, LoadedClerk } from '@clerk/shared/types'; import { type Mocked, vi } from 'vitest'; -import { QueryClient } from '@/core/query-core'; import type { RouteContextValue } from '@/ui/router'; type FunctionLike = (...args: any) => any; @@ -46,19 +46,7 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepVitestMocked defaultQueryClient), - configurable: true, - }); - mockProp(clerkAny, 'navigate'); mockProp(clerkAny, 'setActive'); mockProp(clerkAny, 'redirectWithAuth'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1de302620c..3ca8e00cfbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -440,9 +440,6 @@ importers: '@swc/helpers': specifier: catalog:repo version: 0.5.21 - '@tanstack/query-core': - specifier: catalog:repo - version: 5.100.6 '@wallet-standard/core': specifier: catalog:module-manager version: 1.1.1 @@ -2770,7 +2767,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==}