From bc169ba75867a2a7c8e2255c7214099d91ce54e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 10 Nov 2025 14:35:25 +0100 Subject: [PATCH 01/35] Refactor promises in NextJS ClerkProvider --- .../src/app-router/server/ClerkProvider.tsx | 67 ++++++++++--------- .../app-router/server/keyless-provider.tsx | 14 ++-- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index b610070512b..c81ebaa5e97 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -28,38 +28,41 @@ const getNonceHeaders = React.cache(async function getNonceHeaders() { getScriptNonceFromHeader(headersList.get('Content-Security-Policy') || '') || ''; }); +async function generateStatePromise(dynamic: boolean) { + if (!dynamic) { + return Promise.resolve(null); + } + if (isNext13) { + /** + * For some reason, Next 13 requires that functions which call `headers()` are awaited where they are invoked. + * Without the await here, Next will throw a DynamicServerError during build. + */ + return Promise.resolve(await getDynamicClerkState()); + } + return getDynamicClerkState(); +} + +async function generateNonce(dynamic: boolean) { + if (!dynamic) { + return Promise.resolve(''); + } + if (isNext13) { + /** + * For some reason, Next 13 requires that functions which call `headers()` are awaited where they are invoked. + * Without the await here, Next will throw a DynamicServerError during build. + */ + return Promise.resolve(await getNonceHeaders()); + } + return getNonceHeaders(); +} + export async function ClerkProvider( props: Without, ) { const { children, dynamic, ...rest } = props; - async function generateStatePromise() { - if (!dynamic) { - return Promise.resolve(null); - } - if (isNext13) { - /** - * For some reason, Next 13 requires that functions which call `headers()` are awaited where they are invoked. - * Without the await here, Next will throw a DynamicServerError during build. - */ - return Promise.resolve(await getDynamicClerkState()); - } - return getDynamicClerkState(); - } - - async function generateNonce() { - if (!dynamic) { - return Promise.resolve(''); - } - if (isNext13) { - /** - * For some reason, Next 13 requires that functions which call `headers()` are awaited where they are invoked. - * Without the await here, Next will throw a DynamicServerError during build. - */ - return Promise.resolve(await getNonceHeaders()); - } - return getNonceHeaders(); - } + const statePromise = generateStatePromise(!!dynamic); + const noncePromise = generateNonce(!!dynamic); const propsWithEnvs = mergeNextClerkPropsWithEnv({ ...rest, @@ -82,8 +85,8 @@ export async function ClerkProvider( output = ( {children} @@ -93,8 +96,8 @@ export async function ClerkProvider( output = ( {children} @@ -104,7 +107,7 @@ export async function ClerkProvider( if (dynamic) { return ( // TODO: fix types so AuthObject is compatible with InitialState - }> + }> {output} ); diff --git a/packages/nextjs/src/app-router/server/keyless-provider.tsx b/packages/nextjs/src/app-router/server/keyless-provider.tsx index fa16913af25..f402c47cf55 100644 --- a/packages/nextjs/src/app-router/server/keyless-provider.tsx +++ b/packages/nextjs/src/app-router/server/keyless-provider.tsx @@ -35,12 +35,12 @@ export async function getKeylessStatus( type KeylessProviderProps = PropsWithChildren<{ rest: Without; runningWithClaimedKeys: boolean; - generateStatePromise: () => Promise; - generateNonce: () => Promise; + statePromise: Promise; + noncePromise: Promise; }>; export const KeylessProvider = async (props: KeylessProviderProps) => { - const { rest, runningWithClaimedKeys, generateNonce, generateStatePromise, children } = props; + const { rest, runningWithClaimedKeys, statePromise, noncePromise, children } = props; // NOTE: Create or read keys on every render. Usually this means only on hard refresh or hard navigations. const newOrReadKeys = await import('../../server/keyless-node.js') @@ -56,8 +56,8 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { return ( {children} @@ -75,8 +75,8 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { // Explicitly use `null` instead of `undefined` here to avoid persisting `deleteKeylessAction` during merging of options. __internal_keyless_dismissPrompt: runningWithClaimedKeys ? deleteKeylessAction : null, })} - nonce={await generateNonce()} - initialState={await generateStatePromise()} + nonce={await noncePromise} + initialState={await statePromise} > {children} From 3fa0633dbb860c5aea7515a9d78efd80882a0d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 10 Nov 2025 14:55:06 +0100 Subject: [PATCH 02/35] Remove PromisifiedAuth --- .../src/app-router/server/ClerkProvider.tsx | 11 +-- .../PromisifiedAuthProvider.tsx | 78 ------------------- packages/nextjs/src/client-boundary/hooks.ts | 3 +- 3 files changed, 2 insertions(+), 90 deletions(-) delete mode 100644 packages/nextjs/src/client-boundary/PromisifiedAuthProvider.tsx diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index c81ebaa5e97..8935fbffbb8 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -1,9 +1,8 @@ -import type { InitialState, Without } from '@clerk/shared/types'; +import type { Without } from '@clerk/shared/types'; import { headers } from 'next/headers'; import type { ReactNode } from 'react'; import React from 'react'; -import { PromisifiedAuthProvider } from '../../client-boundary/PromisifiedAuthProvider'; import { getDynamicAuthData } from '../../server/buildClerkProps'; import type { NextClerkProviderProps } from '../../types'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; @@ -104,13 +103,5 @@ export async function ClerkProvider( ); } - if (dynamic) { - return ( - // TODO: fix types so AuthObject is compatible with InitialState - }> - {output} - - ); - } return output; } diff --git a/packages/nextjs/src/client-boundary/PromisifiedAuthProvider.tsx b/packages/nextjs/src/client-boundary/PromisifiedAuthProvider.tsx deleted file mode 100644 index dbed9233ca0..00000000000 --- a/packages/nextjs/src/client-boundary/PromisifiedAuthProvider.tsx +++ /dev/null @@ -1,78 +0,0 @@ -'use client'; - -import { useAuth } from '@clerk/react'; -import { useDerivedAuth } from '@clerk/react/internal'; -import type { InitialState } from '@clerk/shared/types'; -import { useRouter } from 'next/compat/router'; -import React from 'react'; - -const PromisifiedAuthContext = React.createContext | InitialState | null>(null); - -export function PromisifiedAuthProvider({ - authPromise, - children, -}: { - authPromise: Promise | InitialState; - children: React.ReactNode; -}) { - return {children}; -} - -/** - * Returns the current auth state, the user and session ids and the `getToken` - * that can be used to retrieve the given template or the default Clerk token. - * - * Until Clerk loads, `isLoaded` will be set to `false`. - * Once Clerk loads, `isLoaded` will be set to `true`, and you can - * safely access the `userId` and `sessionId` variables. - * - * For projects using NextJs or Remix, you can have immediate access to this data during SSR - * simply by using the `ClerkProvider`. - * - * @example - * import { useAuth } from '@clerk/nextjs' - * - * function Hello() { - * const { isSignedIn, sessionId, userId } = useAuth(); - * if(isSignedIn) { - * return null; - * } - * console.log(sessionId, userId) - * return
...
- * } - * - * @example - * This page will be fully rendered during SSR. - * - * ```tsx - * import { useAuth } from '@clerk/nextjs' - * - * export HelloPage = () => { - * const { isSignedIn, sessionId, userId } = useAuth(); - * console.log(isSignedIn, sessionId, userId) - * return
...
- * } - * ``` - */ -export function usePromisifiedAuth(options: Parameters[0] = {}) { - const isPagesRouter = useRouter(); - const valueFromContext = React.useContext(PromisifiedAuthContext); - - let resolvedData = valueFromContext; - if (valueFromContext && 'then' in valueFromContext) { - resolvedData = React.use(valueFromContext); - } - - // At this point we should have a usable auth object - if (typeof window === 'undefined') { - // Pages router should always use useAuth as it is able to grab initial auth state from context during SSR. - if (isPagesRouter) { - return useAuth(options); - } - - // We don't need to deal with Clerk being loaded here - return useDerivedAuth({ ...resolvedData, ...options }); - } else { - return useAuth({ ...resolvedData, ...options }); - } -} diff --git a/packages/nextjs/src/client-boundary/hooks.ts b/packages/nextjs/src/client-boundary/hooks.ts index 168c2bbba01..465d013c1c3 100644 --- a/packages/nextjs/src/client-boundary/hooks.ts +++ b/packages/nextjs/src/client-boundary/hooks.ts @@ -1,6 +1,7 @@ 'use client'; export { + useAuth, useClerk, useEmailLink, useOrganization, @@ -23,5 +24,3 @@ export { EmailLinkErrorCode, EmailLinkErrorCodeStatus, } from '@clerk/react/errors'; - -export { usePromisifiedAuth as useAuth } from './PromisifiedAuthProvider'; From e5d4d4b12f4ee8665ffb6b256ce12d22a62f5960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 10 Nov 2025 15:11:17 +0100 Subject: [PATCH 03/35] Remove initialAuthState from useAuth --- .../src/hooks/__tests__/useAuth.type.test.ts | 16 ---------------- packages/react/src/hooks/useAuth.ts | 17 +++++------------ 2 files changed, 5 insertions(+), 28 deletions(-) diff --git a/packages/react/src/hooks/__tests__/useAuth.type.test.ts b/packages/react/src/hooks/__tests__/useAuth.type.test.ts index 34ae3a05176..280efed08b5 100644 --- a/packages/react/src/hooks/__tests__/useAuth.type.test.ts +++ b/packages/react/src/hooks/__tests__/useAuth.type.test.ts @@ -1,9 +1,7 @@ -import type { PendingSessionOptions } from '@clerk/shared/types'; import { describe, expectTypeOf, it } from 'vitest'; import type { useAuth } from '../useAuth'; -type UseAuthParameters = Parameters[0]; type HasFunction = Exclude['has'], undefined>; type ParamsOfHas = Parameters[0]; @@ -145,18 +143,4 @@ describe('useAuth type tests', () => { } as const).not.toMatchTypeOf(); }); }); - - describe('with parameters', () => { - it('allows passing any auth state object', () => { - expectTypeOf({ orgId: null }).toMatchTypeOf(); - }); - - it('do not allow invalid option types', () => { - const invalidValue = 5; - expectTypeOf({ treatPendingAsSignedOut: invalidValue } satisfies Record< - keyof PendingSessionOptions, - any - >).toMatchTypeOf(); - }); - }); }); diff --git a/packages/react/src/hooks/useAuth.ts b/packages/react/src/hooks/useAuth.ts index 2f002127697..46a72b2d2ae 100644 --- a/packages/react/src/hooks/useAuth.ts +++ b/packages/react/src/hooks/useAuth.ts @@ -20,7 +20,7 @@ import { createGetToken, createSignOut } from './utils'; /** * @inline */ -type UseAuthOptions = Record | PendingSessionOptions | undefined | null; +type UseAuthOptions = PendingSessionOptions | undefined | null; /** * The `useAuth()` hook provides access to the current user's authentication state and methods to manage the active session. @@ -35,7 +35,7 @@ type UseAuthOptions = Record | PendingSessionOptions | undefined | * @unionReturnHeadings * ["Initialization", "Signed out", "Signed in (no active organization)", "Signed in (with active organization)"] * - * @param [initialAuthStateOrOptions] - An object containing the initial authentication state or options for the `useAuth()` hook. If not provided, the hook will attempt to derive the state from the context. `treatPendingAsSignedOut` is a boolean that indicates whether pending sessions are considered as signed out or not. Defaults to `true`. + * @param [options] - An object containing options for the `useAuth()` hook. `treatPendingAsSignedOut` is a boolean that indicates whether pending sessions are considered as signed out or not. Defaults to `true`. * * @function * @@ -92,18 +92,11 @@ type UseAuthOptions = Record | PendingSessionOptions | undefined | * * */ -export const useAuth = (initialAuthStateOrOptions: UseAuthOptions = {}): UseAuthReturn => { +export const useAuth = (options: UseAuthOptions = {}): UseAuthReturn => { useAssertWrappedByClerkProvider('useAuth'); - const { treatPendingAsSignedOut, ...rest } = initialAuthStateOrOptions ?? {}; - const initialAuthState = rest as any; - + const { treatPendingAsSignedOut } = options ?? {}; const authContextFromHook = useAuthContext(); - let authContext = authContextFromHook; - - if (authContext.sessionId === undefined && authContext.userId === undefined) { - authContext = initialAuthState != null ? initialAuthState : {}; - } const isomorphicClerk = useIsomorphicClerkContext(); const getToken: GetToken = useCallback(createGetToken(isomorphicClerk), [isomorphicClerk]); @@ -113,7 +106,7 @@ export const useAuth = (initialAuthStateOrOptions: UseAuthOptions = {}): UseAuth return useDerivedAuth( { - ...authContext, + ...authContextFromHook, getToken, signOut, }, From 3b6687ed6749bacd54820b1c445a4fd80f56a131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 10 Nov 2025 15:57:04 +0100 Subject: [PATCH 04/35] Update expo useAuth to match new signature --- packages/expo/src/hooks/useAuth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/expo/src/hooks/useAuth.ts b/packages/expo/src/hooks/useAuth.ts index ab44ce7b726..1a4e0f04e72 100644 --- a/packages/expo/src/hooks/useAuth.ts +++ b/packages/expo/src/hooks/useAuth.ts @@ -8,8 +8,8 @@ import { SessionJWTCache } from '../cache'; * This hook extends the useAuth hook to add experimental JWT caching. * The caching is used only when no options are passed to getToken. */ -export const useAuth = (initialAuthState?: any): UseAuthReturn => { - const { getToken: getTokenBase, ...rest } = useAuthBase(initialAuthState); +export const useAuth = (options?: Parameters[0]): UseAuthReturn => { + const { getToken: getTokenBase, ...rest } = useAuthBase(options); const getToken: GetToken = (opts?: GetTokenOptions): Promise => getTokenBase(opts) From 11a38dae3c81a29d29dbce469efe3f4ef0298d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 10 Nov 2025 17:29:24 +0100 Subject: [PATCH 05/35] Add changeset --- .changeset/dry-bobcats-drop.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/dry-bobcats-drop.md diff --git a/.changeset/dry-bobcats-drop.md b/.changeset/dry-bobcats-drop.md new file mode 100644 index 00000000000..d54364aec78 --- /dev/null +++ b/.changeset/dry-bobcats-drop.md @@ -0,0 +1,15 @@ +--- +'@clerk/react': major +'@clerk/expo': major +'@clerk/nextjs': major +'@clerk/react-router': major +'@clerk/tanstack-react-start': major +'@clerk/chrome-extension': patch +'@clerk/elements': patch +--- + +Remove `initialAuthState` option from `useAuth` hook. + +This option was mainly used internally but is no longer necessary, so we are removing it to keep the API simple. + +If you want `useAuth` to return a populated auth state before Clerk has fully loaded, for example during server rendering, see your framework-specific documentation for guidance. From cf02da192171300de3a2a6ab3f57c42cb45f0fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 11 Nov 2025 09:44:42 +0100 Subject: [PATCH 06/35] Remove special isNext13 handling from Next ClerkProvider --- .../src/app-router/server/ClerkProvider.tsx | 40 +++++-------------- 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index 8935fbffbb8..90e21e51b51 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -1,12 +1,10 @@ import type { Without } from '@clerk/shared/types'; import { headers } from 'next/headers'; -import type { ReactNode } from 'react'; import React from 'react'; import { getDynamicAuthData } from '../../server/buildClerkProps'; import type { NextClerkProviderProps } from '../../types'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; -import { isNext13 } from '../../utils/sdk-versions'; import { ClientClerkProvider } from '../client/ClerkProvider'; import { getKeylessStatus, KeylessProvider } from './keyless-provider'; import { buildRequestLike, getScriptNonceFromHeader } from './utils'; @@ -31,13 +29,6 @@ async function generateStatePromise(dynamic: boolean) { if (!dynamic) { return Promise.resolve(null); } - if (isNext13) { - /** - * For some reason, Next 13 requires that functions which call `headers()` are awaited where they are invoked. - * Without the await here, Next will throw a DynamicServerError during build. - */ - return Promise.resolve(await getDynamicClerkState()); - } return getDynamicClerkState(); } @@ -45,13 +36,6 @@ async function generateNonce(dynamic: boolean) { if (!dynamic) { return Promise.resolve(''); } - if (isNext13) { - /** - * For some reason, Next 13 requires that functions which call `headers()` are awaited where they are invoked. - * Without the await here, Next will throw a DynamicServerError during build. - */ - return Promise.resolve(await getNonceHeaders()); - } return getNonceHeaders(); } @@ -69,8 +53,6 @@ export async function ClerkProvider( const { shouldRunAsKeyless, runningWithClaimedKeys } = await getKeylessStatus(propsWithEnvs); - let output: ReactNode; - try { const detectKeylessEnvDrift = await import('../../server/keyless-telemetry.js').then( mod => mod.detectKeylessEnvDrift, @@ -81,7 +63,7 @@ export async function ClerkProvider( } if (shouldRunAsKeyless) { - output = ( + return ( ); - } else { - output = ( - - {children} - - ); } - return output; + return ( + + {children} + + ); } From 9f9477d3b39413c6c55bab0e54e5753e5e898f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 11 Nov 2025 12:14:42 +0100 Subject: [PATCH 07/35] Temporarily remove affected packages from changeset --- .changeset/dry-bobcats-drop.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.changeset/dry-bobcats-drop.md b/.changeset/dry-bobcats-drop.md index d54364aec78..2f3e195bc9b 100644 --- a/.changeset/dry-bobcats-drop.md +++ b/.changeset/dry-bobcats-drop.md @@ -1,11 +1,4 @@ --- -'@clerk/react': major -'@clerk/expo': major -'@clerk/nextjs': major -'@clerk/react-router': major -'@clerk/tanstack-react-start': major -'@clerk/chrome-extension': patch -'@clerk/elements': patch --- Remove `initialAuthState` option from `useAuth` hook. From c7ed16811dad57a8ccc381f51b01acc98a0c48dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 11 Nov 2025 12:17:21 +0100 Subject: [PATCH 08/35] Add back affected packages --- .changeset/dry-bobcats-drop.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.changeset/dry-bobcats-drop.md b/.changeset/dry-bobcats-drop.md index 2f3e195bc9b..de28ae3f458 100644 --- a/.changeset/dry-bobcats-drop.md +++ b/.changeset/dry-bobcats-drop.md @@ -1,4 +1,11 @@ --- +'@clerk/clerk-react': major +'@clerk/expo': major +'@clerk/nextjs': major +'@clerk/react-router': major +'@clerk/tanstack-react-start': major +'@clerk/chrome-extension': patch +'@clerk/elements': patch --- Remove `initialAuthState` option from `useAuth` hook. From 2f455776d8c4e6fc8f42dabe3fbadf731fc9728f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 11 Nov 2025 12:24:12 +0100 Subject: [PATCH 09/35] Fix changeset react package name --- .changeset/dry-bobcats-drop.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/dry-bobcats-drop.md b/.changeset/dry-bobcats-drop.md index de28ae3f458..d54364aec78 100644 --- a/.changeset/dry-bobcats-drop.md +++ b/.changeset/dry-bobcats-drop.md @@ -1,5 +1,5 @@ --- -'@clerk/clerk-react': major +'@clerk/react': major '@clerk/expo': major '@clerk/nextjs': major '@clerk/react-router': major From 09d8a8ae9d2b86a82b84eecdaef20c988ab7f920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 12 Nov 2025 17:20:41 +0100 Subject: [PATCH 10/35] Add InitialAuthStateProvider and refactor to derive authState in useAuth instead of at the ClerkProvider level --- packages/react/src/contexts/AuthContext.ts | 23 ----- packages/react/src/contexts/AuthContext.tsx | 85 +++++++++++++++++++ .../src/contexts/ClerkContextProvider.tsx | 58 ++++--------- .../src/hooks/__tests__/useAuth.test.tsx | 10 ++- packages/react/src/hooks/useAuth.ts | 10 ++- packages/react/src/internal.ts | 2 + packages/shared/src/deriveState.ts | 15 +++- 7 files changed, 127 insertions(+), 76 deletions(-) delete mode 100644 packages/react/src/contexts/AuthContext.ts create mode 100644 packages/react/src/contexts/AuthContext.tsx diff --git a/packages/react/src/contexts/AuthContext.ts b/packages/react/src/contexts/AuthContext.ts deleted file mode 100644 index 0391e2e4a74..00000000000 --- a/packages/react/src/contexts/AuthContext.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createContextAndHook } from '@clerk/shared/react'; -import type { - ActClaim, - JwtPayload, - OrganizationCustomPermissionKey, - OrganizationCustomRoleKey, - SessionStatusClaim, -} from '@clerk/shared/types'; - -export type AuthContextValue = { - userId: string | null | undefined; - sessionId: string | null | undefined; - sessionStatus: SessionStatusClaim | null | undefined; - sessionClaims: JwtPayload | null | undefined; - actor: ActClaim | null | undefined; - orgId: string | null | undefined; - orgRole: OrganizationCustomRoleKey | null | undefined; - orgSlug: string | null | undefined; - orgPermissions: OrganizationCustomPermissionKey[] | null | undefined; - factorVerificationAge: [number, number] | null; -}; - -export const [AuthContext, useAuthContext] = createContextAndHook('AuthContext'); diff --git a/packages/react/src/contexts/AuthContext.tsx b/packages/react/src/contexts/AuthContext.tsx new file mode 100644 index 00000000000..91ad2fd86d4 --- /dev/null +++ b/packages/react/src/contexts/AuthContext.tsx @@ -0,0 +1,85 @@ +import type { DeriveStateReturnType } from '@clerk/shared/deriveState'; +import { deriveFromClientSideState, deriveFromSsrInitialState } from '@clerk/shared/deriveState'; +import { createContextAndHook } from '@clerk/shared/react'; +import type { + ActClaim, + InitialState, + JwtPayload, + OrganizationCustomPermissionKey, + OrganizationCustomRoleKey, + Resources, + SessionStatusClaim, +} from '@clerk/shared/types'; +import React from 'react'; + +export type AuthContextValue = { + userId: string | null | undefined; + sessionId: string | null | undefined; + sessionStatus: SessionStatusClaim | null | undefined; + sessionClaims: JwtPayload | null | undefined; + actor: ActClaim | null | undefined; + orgId: string | null | undefined; + orgRole: OrganizationCustomRoleKey | null | undefined; + orgSlug: string | null | undefined; + orgPermissions: OrganizationCustomPermissionKey[] | null | undefined; + factorVerificationAge: [number, number] | null; +}; + +export const [InitialAuthContext, useInitialAuthContext] = createContextAndHook( + 'InitialAuthContext', +); +export function InitialAuthStateProvider(props: { children: React.ReactNode; initialState: InitialState | undefined }) { + const initialAuthStateCtxValue = useDeriveAuthContext( + props.initialState ? deriveFromSsrInitialState(props.initialState) : undefined, + ); + return {props.children}; +} + +export const [AuthContext, useAuthContext] = createContextAndHook('AuthContext'); +export function AuthStateProvider(props: { children: React.ReactNode; state: Resources }) { + const authStateCtxValue = useDeriveAuthContext(deriveFromClientSideState(props.state)); + return {props.children}; +} + +// Narrow full state to only what we need for the AuthContextValue +function useDeriveAuthContext( + fullState: T, + // We want the types to be: + // Pass in value known not to be undefined -> { value: AuthContextValue } + // Pass in value that might be undefined -> { value: AuthContextValue | undefined } +): { value: T extends undefined ? undefined : AuthContextValue } { + const fullReturn = React.useMemo(() => { + const value = { + sessionId: fullState?.sessionId, + sessionStatus: fullState?.sessionStatus, + sessionClaims: fullState?.sessionClaims, + userId: fullState?.userId, + actor: fullState?.actor, + orgId: fullState?.orgId, + orgRole: fullState?.orgRole, + orgSlug: fullState?.orgSlug, + orgPermissions: fullState?.orgPermissions, + factorVerificationAge: fullState?.factorVerificationAge, + }; + return { value }; + }, [ + fullState?.sessionId, + fullState?.sessionStatus, + fullState?.sessionClaims, + fullState?.userId, + fullState?.actor, + fullState?.orgId, + fullState?.orgRole, + fullState?.orgSlug, + fullState?.orgPermissions, + fullState?.factorVerificationAge, + ]); + + const emptyAuthCtx = React.useMemo(() => ({ value: undefined }), []); + + if (fullState === undefined) { + return emptyAuthCtx as { value: T extends undefined ? undefined : AuthContextValue }; + } + + return fullReturn as { value: T extends undefined ? undefined : AuthContextValue }; +} diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index 09f2ce7eb04..284934a7425 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { IsomorphicClerk } from '../isomorphicClerk'; import type { IsomorphicClerkOptions } from '../types'; -import { AuthContext } from './AuthContext'; +import { AuthStateProvider, InitialAuthStateProvider } from './AuthContext'; import { IsomorphicClerkContext } from './IsomorphicClerkContext'; type ClerkContextProvider = { @@ -37,7 +37,6 @@ export function ClerkContextProvider(props: ClerkContextProvider) { return clerk.addListener(e => setState({ ...e })); }, []); - const derivedState = deriveState(clerk.loaded, state, initialState); const clerkCtx = React.useMemo( () => ({ value: clerk }), [ @@ -47,37 +46,8 @@ export function ClerkContextProvider(props: ClerkContextProvider) { ); const clientCtx = React.useMemo(() => ({ value: state.client }), [state.client]); - const { - sessionId, - sessionStatus, - sessionClaims, - session, - userId, - user, - orgId, - actor, - organization, - orgRole, - orgSlug, - orgPermissions, - factorVerificationAge, - } = derivedState; - - const authCtx = React.useMemo(() => { - const value = { - sessionId, - sessionStatus, - sessionClaims, - userId, - actor, - orgId, - orgRole, - orgSlug, - orgPermissions, - factorVerificationAge, - }; - return { value }; - }, [sessionId, sessionStatus, userId, actor, orgId, orgRole, orgSlug, factorVerificationAge, sessionClaims?.__raw]); + const resolvedState = deriveState(clerk.loaded, state, initialState); + const { sessionId, session, userId, user, orgId, organization } = resolvedState; const sessionCtx = React.useMemo(() => ({ value: session }), [sessionId, session]); const userCtx = React.useMemo(() => ({ value: user }), [userId, user]); @@ -94,16 +64,18 @@ export function ClerkContextProvider(props: ClerkContextProvider) { - - - - {children} - - - + + + + + {children} + + + + diff --git a/packages/react/src/hooks/__tests__/useAuth.test.tsx b/packages/react/src/hooks/__tests__/useAuth.test.tsx index fcffe1bdc17..2e1d9af4a10 100644 --- a/packages/react/src/hooks/__tests__/useAuth.test.tsx +++ b/packages/react/src/hooks/__tests__/useAuth.test.tsx @@ -5,7 +5,7 @@ import { render, renderHook } from '@testing-library/react'; import React from 'react'; import { afterAll, beforeAll, beforeEach, describe, expect, expectTypeOf, it, test, vi } from 'vitest'; -import { AuthContext } from '../../contexts/AuthContext'; +import { AuthContext, InitialAuthContext } from '../../contexts/AuthContext'; import { errorThrower } from '../../errors/errorThrower'; import { invalidStateError } from '../../errors/messages'; import { useAuth, useDerivedAuth } from '../useAuth'; @@ -70,9 +70,11 @@ describe('useAuth', () => { expect(() => { render( - - - + + + + + , ); }).not.toThrow(); diff --git a/packages/react/src/hooks/useAuth.ts b/packages/react/src/hooks/useAuth.ts index 46a72b2d2ae..72580b21a6e 100644 --- a/packages/react/src/hooks/useAuth.ts +++ b/packages/react/src/hooks/useAuth.ts @@ -10,7 +10,7 @@ import type { } from '@clerk/shared/types'; import { useCallback } from 'react'; -import { useAuthContext } from '../contexts/AuthContext'; +import { useAuthContext, useInitialAuthContext } from '../contexts/AuthContext'; import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext'; import { errorThrower } from '../errors/errorThrower'; import { invalidStateError } from '../errors/messages'; @@ -96,7 +96,11 @@ export const useAuth = (options: UseAuthOptions = {}): UseAuthReturn => { useAssertWrappedByClerkProvider('useAuth'); const { treatPendingAsSignedOut } = options ?? {}; - const authContextFromHook = useAuthContext(); + const clerk = useIsomorphicClerkContext(); + const initialAuthContext = useInitialAuthContext(); + const authContext = useAuthContext(); + + const resolvedAuthContext = !clerk.loaded && initialAuthContext !== undefined ? initialAuthContext : authContext; const isomorphicClerk = useIsomorphicClerkContext(); const getToken: GetToken = useCallback(createGetToken(isomorphicClerk), [isomorphicClerk]); @@ -106,7 +110,7 @@ export const useAuth = (options: UseAuthOptions = {}): UseAuthReturn => { return useDerivedAuth( { - ...authContextFromHook, + ...resolvedAuthContext, getToken, signOut, }, diff --git a/packages/react/src/internal.ts b/packages/react/src/internal.ts index 3ee8e579cc6..df7eb628819 100644 --- a/packages/react/src/internal.ts +++ b/packages/react/src/internal.ts @@ -8,3 +8,5 @@ export { buildClerkJsScriptAttributes, setClerkJsLoadingErrorPackageName, } from '@clerk/shared/loadClerkJsScript'; + +export { InitialAuthStateProvider } from './contexts/AuthContext'; diff --git a/packages/shared/src/deriveState.ts b/packages/shared/src/deriveState.ts index 7b84fb42d2c..5994af6726f 100644 --- a/packages/shared/src/deriveState.ts +++ b/packages/shared/src/deriveState.ts @@ -9,17 +9,26 @@ import type { UserResource, } from './types'; +// We use the ReturnType of deriveFromSsrInitialState, which in turn uses the ReturnType of deriveFromClientSideState, +// to ensure these stay in sync without having to manually type them out. +export type DeriveStateReturnType = ReturnType; + /** * Derives authentication state based on the current rendering context (SSR or client-side). */ -export const deriveState = (clerkOperational: boolean, state: Resources, initialState: InitialState | undefined) => { +export const deriveState = ( + clerkOperational: boolean, + state: Resources, + initialState: InitialState | undefined, +): DeriveStateReturnType => { if (!clerkOperational && initialState) { return deriveFromSsrInitialState(initialState); } return deriveFromClientSideState(state); }; -const deriveFromSsrInitialState = (initialState: InitialState) => { +// We use the ReturnType of deriveFromClientSideState to ensure these stay in sync +export const deriveFromSsrInitialState = (initialState: InitialState): ReturnType => { const userId = initialState.userId; const user = initialState.user as UserResource; const sessionId = initialState.sessionId; @@ -51,7 +60,7 @@ const deriveFromSsrInitialState = (initialState: InitialState) => { }; }; -const deriveFromClientSideState = (state: Resources) => { +export const deriveFromClientSideState = (state: Resources) => { const userId: string | null | undefined = state.user ? state.user.id : state.user; const user = state.user; const sessionId: string | null | undefined = state.session ? state.session.id : state.session; From 31d4f2b1fec5bac26e30e0d119faf502671db23d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 12 Nov 2025 17:21:31 +0100 Subject: [PATCH 11/35] Use InitialAuthStateProvider directly for nested Next ClerkProvider --- .../src/app-router/client/ClerkProvider.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index f3d981657f3..a3c5a79d8ac 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -1,5 +1,6 @@ 'use client'; import { ClerkProvider as ReactClerkProvider } from '@clerk/react'; +import { InitialAuthStateProvider as ReactInitialAuthStateProvider } from '@clerk/react/internal'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; import React from 'react'; @@ -37,12 +38,6 @@ const NextClientClerkProvider = (props: NextClerkProviderProps) => { } }, []); - // Avoid rendering nested ClerkProviders by checking for the existence of the ClerkNextOptions context provider - const isNested = Boolean(useClerkNextOptions()); - if (isNested) { - return props.children; - } - useSafeLayoutEffect(() => { window.__unstable__onBeforeSetActive = intent => { /** @@ -112,6 +107,16 @@ export const ClientClerkProvider = (props: NextClerkProviderProps & { disableKey const { children, disableKeyless = false, ...rest } = props; const safePublishableKey = mergeNextClerkPropsWithEnv(rest).publishableKey; + // Avoid rendering nested ClerkProviders by checking for the existence of the ClerkNextOptions context provider + const isNested = Boolean(useClerkNextOptions()); + if (isNested) { + if (rest.initialState) { + // If using inside a , we do want the initial state to be available for this subtree + return {children}; + } + return children; + } + if (safePublishableKey || !canUseKeyless || disableKeyless) { return {children}; } From 7a5381c3007f8c738aea1be9104b4ac1665bb5c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 13 Nov 2025 12:26:02 +0100 Subject: [PATCH 12/35] Resolve !dynamic without promises --- .../src/app-router/server/ClerkProvider.tsx | 26 +++++-------------- .../app-router/server/keyless-provider.tsx | 14 +++++----- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index 90e21e51b51..81b393983be 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -25,27 +25,13 @@ const getNonceHeaders = React.cache(async function getNonceHeaders() { getScriptNonceFromHeader(headersList.get('Content-Security-Policy') || '') || ''; }); -async function generateStatePromise(dynamic: boolean) { - if (!dynamic) { - return Promise.resolve(null); - } - return getDynamicClerkState(); -} - -async function generateNonce(dynamic: boolean) { - if (!dynamic) { - return Promise.resolve(''); - } - return getNonceHeaders(); -} - export async function ClerkProvider( props: Without, ) { const { children, dynamic, ...rest } = props; - const statePromise = generateStatePromise(!!dynamic); - const noncePromise = generateNonce(!!dynamic); + const statePromiseOrValue = dynamic ? getDynamicClerkState() : null; + const noncePromiseOrValue = dynamic ? getNonceHeaders() : ''; const propsWithEnvs = mergeNextClerkPropsWithEnv({ ...rest, @@ -66,8 +52,8 @@ export async function ClerkProvider( return ( {children} @@ -78,8 +64,8 @@ export async function ClerkProvider( return ( {children} diff --git a/packages/nextjs/src/app-router/server/keyless-provider.tsx b/packages/nextjs/src/app-router/server/keyless-provider.tsx index f402c47cf55..b559bf20acb 100644 --- a/packages/nextjs/src/app-router/server/keyless-provider.tsx +++ b/packages/nextjs/src/app-router/server/keyless-provider.tsx @@ -35,12 +35,12 @@ export async function getKeylessStatus( type KeylessProviderProps = PropsWithChildren<{ rest: Without; runningWithClaimedKeys: boolean; - statePromise: Promise; - noncePromise: Promise; + initialState: AuthObject | null; + nonce: string; }>; export const KeylessProvider = async (props: KeylessProviderProps) => { - const { rest, runningWithClaimedKeys, statePromise, noncePromise, children } = props; + const { rest, runningWithClaimedKeys, initialState, nonce, children } = props; // NOTE: Create or read keys on every render. Usually this means only on hard refresh or hard navigations. const newOrReadKeys = await import('../../server/keyless-node.js') @@ -56,8 +56,8 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { return ( {children} @@ -75,8 +75,8 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { // Explicitly use `null` instead of `undefined` here to avoid persisting `deleteKeylessAction` during merging of options. __internal_keyless_dismissPrompt: runningWithClaimedKeys ? deleteKeylessAction : null, })} - nonce={await noncePromise} - initialState={await statePromise} + nonce={nonce} + initialState={initialState} > {children} From ba26f9c1cdb45f54b637cb1d935fc28f0ecb51e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 19 Nov 2025 10:53:24 +0100 Subject: [PATCH 13/35] Clean up AuthContext --- packages/react/src/contexts/AuthContext.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/react/src/contexts/AuthContext.tsx b/packages/react/src/contexts/AuthContext.tsx index 91ad2fd86d4..321c91a71eb 100644 --- a/packages/react/src/contexts/AuthContext.tsx +++ b/packages/react/src/contexts/AuthContext.tsx @@ -41,13 +41,14 @@ export function AuthStateProvider(props: { children: React.ReactNode; state: Res return {props.children}; } +const emptyAuthCtx = { value: undefined }; +// We want the types to be: +// Pass in value known not to be undefined -> { value: AuthContextValue } +// Pass in value that might be undefined -> { value: AuthContextValue | undefined } +type DerivedAuthContextValue = { value: T extends undefined ? undefined : AuthContextValue }; + // Narrow full state to only what we need for the AuthContextValue -function useDeriveAuthContext( - fullState: T, - // We want the types to be: - // Pass in value known not to be undefined -> { value: AuthContextValue } - // Pass in value that might be undefined -> { value: AuthContextValue | undefined } -): { value: T extends undefined ? undefined : AuthContextValue } { +function useDeriveAuthContext(fullState: T): DerivedAuthContextValue { const fullReturn = React.useMemo(() => { const value = { sessionId: fullState?.sessionId, @@ -75,11 +76,9 @@ function useDeriveAuthContext( fullState?.factorVerificationAge, ]); - const emptyAuthCtx = React.useMemo(() => ({ value: undefined }), []); - if (fullState === undefined) { - return emptyAuthCtx as { value: T extends undefined ? undefined : AuthContextValue }; + return emptyAuthCtx as DerivedAuthContextValue; } - return fullReturn as { value: T extends undefined ? undefined : AuthContextValue }; + return fullReturn as DerivedAuthContextValue; } From a2adbf306c3d2c88bad62ebcda6a87eaef5ef6b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 19 Nov 2025 19:47:11 +0100 Subject: [PATCH 14/35] Remove AuthContext and add uSES for useAuth hook --- packages/react/src/contexts/AuthContext.tsx | 125 +++++++++++------- .../src/contexts/ClerkContextProvider.tsx | 20 ++- packages/react/src/hooks/useAuth.ts | 10 +- 3 files changed, 87 insertions(+), 68 deletions(-) diff --git a/packages/react/src/contexts/AuthContext.tsx b/packages/react/src/contexts/AuthContext.tsx index 321c91a71eb..05c6f8d0b02 100644 --- a/packages/react/src/contexts/AuthContext.tsx +++ b/packages/react/src/contexts/AuthContext.tsx @@ -3,16 +3,18 @@ import { deriveFromClientSideState, deriveFromSsrInitialState } from '@clerk/sha import { createContextAndHook } from '@clerk/shared/react'; import type { ActClaim, + ClientResource, InitialState, JwtPayload, OrganizationCustomPermissionKey, OrganizationCustomRoleKey, - Resources, SessionStatusClaim, } from '@clerk/shared/types'; -import React from 'react'; +import React, { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react'; -export type AuthContextValue = { +import { useIsomorphicClerkContext } from './IsomorphicClerkContext'; + +type AuthStateValue = { userId: string | null | undefined; sessionId: string | null | undefined; sessionStatus: SessionStatusClaim | null | undefined; @@ -25,60 +27,83 @@ export type AuthContextValue = { factorVerificationAge: [number, number] | null; }; -export const [InitialAuthContext, useInitialAuthContext] = createContextAndHook( +const [InitialAuthContext, useInitialAuthContext] = createContextAndHook( 'InitialAuthContext', ); -export function InitialAuthStateProvider(props: { children: React.ReactNode; initialState: InitialState | undefined }) { - const initialAuthStateCtxValue = useDeriveAuthContext( - props.initialState ? deriveFromSsrInitialState(props.initialState) : undefined, - ); - return {props.children}; +export function InitialAuthStateProvider({ + children, + initialState, +}: { + children: React.ReactNode; + initialState: InitialState | undefined; +}) { + const initialStateCtx = useMemo(() => ({ value: initialState }), [initialState]); + return {children}; } -export const [AuthContext, useAuthContext] = createContextAndHook('AuthContext'); -export function AuthStateProvider(props: { children: React.ReactNode; state: Resources }) { - const authStateCtxValue = useDeriveAuthContext(deriveFromClientSideState(props.state)); - return {props.children}; -} +export const defaultDerivedInitialState = { + actor: undefined, + factorVerificationAge: null, + orgId: undefined, + orgPermissions: undefined, + orgRole: undefined, + orgSlug: undefined, + sessionClaims: undefined, + sessionId: undefined, + sessionStatus: undefined, + userId: undefined, +}; -const emptyAuthCtx = { value: undefined }; -// We want the types to be: -// Pass in value known not to be undefined -> { value: AuthContextValue } -// Pass in value that might be undefined -> { value: AuthContextValue | undefined } -type DerivedAuthContextValue = { value: T extends undefined ? undefined : AuthContextValue }; +export function useAuthState(): AuthStateValue { + const clerk = useIsomorphicClerkContext(); + const initialAuthContext = useInitialAuthContext(); + // If we make initialState support a promise in the future, this is where we would use() that promise + const initialSnapshot = useMemo(() => { + if (!initialAuthContext) { + return defaultDerivedInitialState; + } + const fullState = deriveFromSsrInitialState(initialAuthContext); + return authStateFromFull(fullState); + }, [initialAuthContext]); -// Narrow full state to only what we need for the AuthContextValue -function useDeriveAuthContext(fullState: T): DerivedAuthContextValue { - const fullReturn = React.useMemo(() => { - const value = { - sessionId: fullState?.sessionId, - sessionStatus: fullState?.sessionStatus, - sessionClaims: fullState?.sessionClaims, - userId: fullState?.userId, - actor: fullState?.actor, - orgId: fullState?.orgId, - orgRole: fullState?.orgRole, - orgSlug: fullState?.orgSlug, - orgPermissions: fullState?.orgPermissions, - factorVerificationAge: fullState?.factorVerificationAge, + const snapshot = useMemo(() => { + if (!clerk.loaded) { + return initialSnapshot; + } + const state = { + client: clerk.client as ClientResource, + session: clerk.session, + user: clerk.user, + organization: clerk.organization, }; - return { value }; - }, [ - fullState?.sessionId, - fullState?.sessionStatus, - fullState?.sessionClaims, - fullState?.userId, - fullState?.actor, - fullState?.orgId, - fullState?.orgRole, - fullState?.orgSlug, - fullState?.orgPermissions, - fullState?.factorVerificationAge, - ]); + const fullState = deriveFromClientSideState(state); + return authStateFromFull(fullState); + }, [clerk.client, clerk.session, clerk.user, clerk.organization, initialSnapshot, clerk.loaded]); - if (fullState === undefined) { - return emptyAuthCtx as DerivedAuthContextValue; - } + const authState = useSyncExternalStore( + clerk.addListener, + useCallback(() => snapshot, [snapshot]), + useCallback(() => initialSnapshot, [initialSnapshot]), + ); + + // If an updates comes in during a transition, uSES usually deopts that transition to be synchronous, + // which for example means that already mounted boundaries might suddenly show their fallback. + // This makes all auth state changes into transitions, but does not deopt to be synchronous. If it's + // called during a transition, it immediately uses the new value without deferring. + return useDeferredValue(authState); +} - return fullReturn as DerivedAuthContextValue; +function authStateFromFull(derivedState: DeriveStateReturnType) { + return { + sessionId: derivedState.sessionId, + sessionStatus: derivedState.sessionStatus, + sessionClaims: derivedState.sessionClaims, + userId: derivedState.userId, + actor: derivedState.actor, + orgId: derivedState.orgId, + orgRole: derivedState.orgRole, + orgSlug: derivedState.orgSlug, + orgPermissions: derivedState.orgPermissions, + factorVerificationAge: derivedState.factorVerificationAge, + }; } diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index 284934a7425..78923c9f8df 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { IsomorphicClerk } from '../isomorphicClerk'; import type { IsomorphicClerkOptions } from '../types'; -import { AuthStateProvider, InitialAuthStateProvider } from './AuthContext'; +import { InitialAuthStateProvider } from './AuthContext'; import { IsomorphicClerkContext } from './IsomorphicClerkContext'; type ClerkContextProvider = { @@ -65,16 +65,14 @@ export function ClerkContextProvider(props: ClerkContextProvider) { - - - - {children} - - - + + + {children} + + diff --git a/packages/react/src/hooks/useAuth.ts b/packages/react/src/hooks/useAuth.ts index 72580b21a6e..be1096d1330 100644 --- a/packages/react/src/hooks/useAuth.ts +++ b/packages/react/src/hooks/useAuth.ts @@ -9,8 +9,8 @@ import type { UseAuthReturn, } from '@clerk/shared/types'; import { useCallback } from 'react'; +import { useAuthState } from 'src/contexts/AuthContext'; -import { useAuthContext, useInitialAuthContext } from '../contexts/AuthContext'; import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext'; import { errorThrower } from '../errors/errorThrower'; import { invalidStateError } from '../errors/messages'; @@ -96,11 +96,7 @@ export const useAuth = (options: UseAuthOptions = {}): UseAuthReturn => { useAssertWrappedByClerkProvider('useAuth'); const { treatPendingAsSignedOut } = options ?? {}; - const clerk = useIsomorphicClerkContext(); - const initialAuthContext = useInitialAuthContext(); - const authContext = useAuthContext(); - - const resolvedAuthContext = !clerk.loaded && initialAuthContext !== undefined ? initialAuthContext : authContext; + const authState = useAuthState(); const isomorphicClerk = useIsomorphicClerkContext(); const getToken: GetToken = useCallback(createGetToken(isomorphicClerk), [isomorphicClerk]); @@ -110,7 +106,7 @@ export const useAuth = (options: UseAuthOptions = {}): UseAuthReturn => { return useDerivedAuth( { - ...resolvedAuthContext, + ...authState, getToken, signOut, }, From c85a66c49926885aba3384a51db4847b185eadbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 19 Nov 2025 20:09:00 +0100 Subject: [PATCH 15/35] Move ClerkContextProvider from ui to shared/react --- .../src/react/ClerkContextProvider.tsx} | 14 +++++++------- packages/shared/src/react/index.ts | 2 ++ packages/shared/src/react/utils.ts | 8 ++++++++ packages/ui/src/contexts/index.ts | 2 +- packages/ui/src/contexts/utils.ts | 12 +----------- packages/ui/src/lazyModules/providers.tsx | 6 +++--- 6 files changed, 22 insertions(+), 22 deletions(-) rename packages/{ui/src/contexts/CoreClerkContextWrapper.tsx => shared/src/react/ClerkContextProvider.tsx} (87%) create mode 100644 packages/shared/src/react/utils.ts diff --git a/packages/ui/src/contexts/CoreClerkContextWrapper.tsx b/packages/shared/src/react/ClerkContextProvider.tsx similarity index 87% rename from packages/ui/src/contexts/CoreClerkContextWrapper.tsx rename to packages/shared/src/react/ClerkContextProvider.tsx index 85e451a784d..81cbef272e6 100644 --- a/packages/ui/src/contexts/CoreClerkContextWrapper.tsx +++ b/packages/shared/src/react/ClerkContextProvider.tsx @@ -1,3 +1,6 @@ +import React from 'react'; + +import type { Clerk, LoadedClerk, Resources } from '../types'; import { __experimental_CheckoutProvider as CheckoutProvider, ClerkInstanceContext, @@ -5,13 +8,10 @@ import { OrganizationProvider, SessionContext, UserContext, -} from '@clerk/shared/react'; -import type { Clerk, LoadedClerk, Resources } from '@clerk/shared/types'; -import React from 'react'; - +} from './contexts'; import { assertClerkSingletonExists } from './utils'; -type CoreClerkContextWrapperProps = { +type ClerkContextProps = { clerk: Clerk; children: React.ReactNode; swrConfig?: any; @@ -19,9 +19,9 @@ type CoreClerkContextWrapperProps = { type CoreClerkContextProviderState = Resources; -export function CoreClerkContextWrapper(props: CoreClerkContextWrapperProps): JSX.Element | null { - // TODO: Revise Clerk and LoadedClerk +export function ClerkContextProvider(props: ClerkContextProps): JSX.Element | null { const clerk = props.clerk as LoadedClerk; + assertClerkSingletonExists(clerk); const [state, setState] = React.useState({ diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index c1f8f761236..4932c9d1fa0 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -18,3 +18,5 @@ export { } from './contexts'; export * from './commerce'; + +export { ClerkContextProvider } from './ClerkContextProvider'; diff --git a/packages/shared/src/react/utils.ts b/packages/shared/src/react/utils.ts new file mode 100644 index 00000000000..c404daa0b7b --- /dev/null +++ b/packages/shared/src/react/utils.ts @@ -0,0 +1,8 @@ +import { clerkCoreErrorNoClerkSingleton } from '../internal/clerk-js/errors'; +import type { Clerk } from '../types'; + +export function assertClerkSingletonExists(clerk: Clerk | undefined): asserts clerk is Clerk { + if (!clerk) { + clerkCoreErrorNoClerkSingleton(); + } +} diff --git a/packages/ui/src/contexts/index.ts b/packages/ui/src/contexts/index.ts index 1ae2a67e02c..82a284bb334 100644 --- a/packages/ui/src/contexts/index.ts +++ b/packages/ui/src/contexts/index.ts @@ -4,5 +4,5 @@ export * from './EnvironmentContext'; export * from './OptionsContext'; export * from './CoreSessionContext'; export * from './CoreClientContext'; -export * from './CoreClerkContextWrapper'; export * from './AcceptedUserInvitations'; +export { ClerkContextProvider } from '@clerk/shared/react'; diff --git a/packages/ui/src/contexts/utils.ts b/packages/ui/src/contexts/utils.ts index 423f5151701..260c8965dbe 100644 --- a/packages/ui/src/contexts/utils.ts +++ b/packages/ui/src/contexts/utils.ts @@ -1,18 +1,8 @@ -import { - clerkCoreErrorContextProviderNotFound, - clerkCoreErrorNoClerkSingleton, -} from '@clerk/shared/internal/clerk-js/errors'; -import type { Clerk } from '@clerk/shared/types'; +import { clerkCoreErrorContextProviderNotFound } from '@clerk/shared/internal/clerk-js/errors'; import { snakeToCamel } from '@clerk/shared/underscore'; import { createDynamicParamParser } from '../utils/dynamicParamParser'; -export function assertClerkSingletonExists(clerk: Clerk | undefined): asserts clerk is Clerk { - if (!clerk) { - clerkCoreErrorNoClerkSingleton(); - } -} - export function assertContextExists(contextVal: unknown, providerName: string): asserts contextVal { if (!contextVal) { clerkCoreErrorContextProviderNotFound(providerName); diff --git a/packages/ui/src/lazyModules/providers.tsx b/packages/ui/src/lazyModules/providers.tsx index 9e2c871f23d..7404246bbd4 100644 --- a/packages/ui/src/lazyModules/providers.tsx +++ b/packages/ui/src/lazyModules/providers.tsx @@ -9,7 +9,7 @@ import type { AvailableComponentCtx } from '../types'; import type { ClerkComponentName } from './components'; import { ClerkComponents } from './components'; -const CoreClerkContextWrapper = lazy(() => import('../contexts').then(m => ({ default: m.CoreClerkContextWrapper }))); +const ClerkContextProvider = lazy(() => import('../contexts').then(m => ({ default: m.ClerkContextProvider }))); const EnvironmentProvider = lazy(() => import('../contexts').then(m => ({ default: m.EnvironmentProvider }))); const OptionsProvider = lazy(() => import('../contexts').then(m => ({ default: m.OptionsProvider }))); const AppearanceProvider = lazy(() => import('../customizables').then(m => ({ default: m.AppearanceProvider }))); @@ -40,11 +40,11 @@ export const LazyProviders = (props: LazyProvidersProps) => { nonce={props.options.nonce} cssLayerName={props.options.appearance?.cssLayerName} > - + {props.children} - + ); }; From f35bbbb380ac844718f24d297a82d320311f8301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 19 Nov 2025 20:51:24 +0100 Subject: [PATCH 16/35] Update shared ClerkContextProvider to support initialState and switch react package to use it --- .../src/app-router/client/ClerkProvider.tsx | 4 +- packages/react/src/contexts/AuthContext.tsx | 27 +---- .../src/contexts/ClerkContextProvider.tsx | 107 ------------------ packages/react/src/contexts/ClerkProvider.tsx | 36 +++++- .../src/contexts/IsomorphicClerkContext.tsx | 3 +- packages/react/src/internal.ts | 2 - .../shared/src/react/ClerkContextProvider.tsx | 61 ++++++---- packages/shared/src/react/contexts.tsx | 16 +++ packages/shared/src/react/index.ts | 2 + 9 files changed, 98 insertions(+), 160 deletions(-) delete mode 100644 packages/react/src/contexts/ClerkContextProvider.tsx diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index 5a35a71d719..55da31e967b 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -1,6 +1,6 @@ 'use client'; import { ClerkProvider as ReactClerkProvider } from '@clerk/react'; -import { InitialAuthStateProvider as ReactInitialAuthStateProvider } from '@clerk/react/internal'; +import { InitialStateProvider } from '@clerk/shared/react'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; import React from 'react'; @@ -112,7 +112,7 @@ export const ClientClerkProvider = (props: NextClerkProviderProps & { disableKey if (isNested) { if (rest.initialState) { // If using inside a , we do want the initial state to be available for this subtree - return {children}; + return {children}; } return children; } diff --git a/packages/react/src/contexts/AuthContext.tsx b/packages/react/src/contexts/AuthContext.tsx index 05c6f8d0b02..6f47542893d 100644 --- a/packages/react/src/contexts/AuthContext.tsx +++ b/packages/react/src/contexts/AuthContext.tsx @@ -1,16 +1,15 @@ import type { DeriveStateReturnType } from '@clerk/shared/deriveState'; import { deriveFromClientSideState, deriveFromSsrInitialState } from '@clerk/shared/deriveState'; -import { createContextAndHook } from '@clerk/shared/react'; +import { useInitialStateContext } from '@clerk/shared/react'; import type { ActClaim, ClientResource, - InitialState, JwtPayload, OrganizationCustomPermissionKey, OrganizationCustomRoleKey, SessionStatusClaim, } from '@clerk/shared/types'; -import React, { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react'; +import { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react'; import { useIsomorphicClerkContext } from './IsomorphicClerkContext'; @@ -27,20 +26,6 @@ type AuthStateValue = { factorVerificationAge: [number, number] | null; }; -const [InitialAuthContext, useInitialAuthContext] = createContextAndHook( - 'InitialAuthContext', -); -export function InitialAuthStateProvider({ - children, - initialState, -}: { - children: React.ReactNode; - initialState: InitialState | undefined; -}) { - const initialStateCtx = useMemo(() => ({ value: initialState }), [initialState]); - return {children}; -} - export const defaultDerivedInitialState = { actor: undefined, factorVerificationAge: null, @@ -56,15 +41,15 @@ export const defaultDerivedInitialState = { export function useAuthState(): AuthStateValue { const clerk = useIsomorphicClerkContext(); - const initialAuthContext = useInitialAuthContext(); + const initialStateContext = useInitialStateContext(); // If we make initialState support a promise in the future, this is where we would use() that promise const initialSnapshot = useMemo(() => { - if (!initialAuthContext) { + if (!initialStateContext) { return defaultDerivedInitialState; } - const fullState = deriveFromSsrInitialState(initialAuthContext); + const fullState = deriveFromSsrInitialState(initialStateContext); return authStateFromFull(fullState); - }, [initialAuthContext]); + }, [initialStateContext]); const snapshot = useMemo(() => { if (!clerk.loaded) { diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx deleted file mode 100644 index 78923c9f8df..00000000000 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { deriveState } from '@clerk/shared/deriveState'; -import { - __experimental_CheckoutProvider as CheckoutProvider, - ClientContext, - OrganizationProvider, - SessionContext, - UserContext, -} from '@clerk/shared/react'; -import type { ClientResource, InitialState, Resources } from '@clerk/shared/types'; -import React from 'react'; - -import { IsomorphicClerk } from '../isomorphicClerk'; -import type { IsomorphicClerkOptions } from '../types'; -import { InitialAuthStateProvider } from './AuthContext'; -import { IsomorphicClerkContext } from './IsomorphicClerkContext'; - -type ClerkContextProvider = { - isomorphicClerkOptions: IsomorphicClerkOptions; - initialState: InitialState | undefined; - children: React.ReactNode; -}; - -export type ClerkContextProviderState = Resources; - -export function ClerkContextProvider(props: ClerkContextProvider) { - const { isomorphicClerkOptions, initialState, children } = props; - const { isomorphicClerk: clerk, clerkStatus } = useLoadedIsomorphicClerk(isomorphicClerkOptions); - - const [state, setState] = React.useState({ - client: clerk.client as ClientResource, - session: clerk.session, - user: clerk.user, - organization: clerk.organization, - }); - - React.useEffect(() => { - return clerk.addListener(e => setState({ ...e })); - }, []); - - const clerkCtx = React.useMemo( - () => ({ value: clerk }), - [ - // Only update the clerk reference on status change - clerkStatus, - ], - ); - const clientCtx = React.useMemo(() => ({ value: state.client }), [state.client]); - - const resolvedState = deriveState(clerk.loaded, state, initialState); - const { sessionId, session, userId, user, orgId, organization } = resolvedState; - - const sessionCtx = React.useMemo(() => ({ value: session }), [sessionId, session]); - const userCtx = React.useMemo(() => ({ value: user }), [userId, user]); - const organizationCtx = React.useMemo(() => { - const value = { - organization: organization, - }; - return { value }; - }, [orgId, organization]); - - return ( - // @ts-expect-error value passed is of type IsomorphicClerk where the context expects LoadedClerk - - - - - - - - {children} - - - - - - - - ); -} - -const useLoadedIsomorphicClerk = (options: IsomorphicClerkOptions) => { - const isomorphicClerkRef = React.useRef(IsomorphicClerk.getOrCreateInstance(options)); - const [clerkStatus, setClerkStatus] = React.useState(isomorphicClerkRef.current.status); - - React.useEffect(() => { - void isomorphicClerkRef.current.__unstable__updateProps({ appearance: options.appearance }); - }, [options.appearance]); - - React.useEffect(() => { - void isomorphicClerkRef.current.__unstable__updateProps({ options }); - }, [options.localization]); - - React.useEffect(() => { - isomorphicClerkRef.current.on('status', setClerkStatus); - return () => { - if (isomorphicClerkRef.current) { - isomorphicClerkRef.current.off('status', setClerkStatus); - } - IsomorphicClerk.clearInstance(); - }; - }, []); - - return { isomorphicClerk: isomorphicClerkRef.current, clerkStatus }; -}; diff --git a/packages/react/src/contexts/ClerkProvider.tsx b/packages/react/src/contexts/ClerkProvider.tsx index 66b21ed8a35..f7ff2c9a3d3 100644 --- a/packages/react/src/contexts/ClerkProvider.tsx +++ b/packages/react/src/contexts/ClerkProvider.tsx @@ -1,11 +1,12 @@ import { isPublishableKey } from '@clerk/shared/keys'; +import { ClerkContextProvider } from '@clerk/shared/react'; import React from 'react'; import { errorThrower } from '../errors/errorThrower'; import { multipleClerkProvidersError } from '../errors/messages'; -import type { ClerkProviderProps } from '../types'; +import { IsomorphicClerk } from '../isomorphicClerk'; +import type { ClerkProviderProps, IsomorphicClerkOptions } from '../types'; import { withMaxAllowedInstancesGuard } from '../utils'; -import { ClerkContextProvider } from './ClerkContextProvider'; function ClerkProviderBase(props: ClerkProviderProps) { const { initialState, children, __internal_bypassMissingPublishableKey, ...restIsomorphicClerkOptions } = props; @@ -19,10 +20,14 @@ function ClerkProviderBase(props: ClerkProviderProps) { } } + const { isomorphicClerk, clerkStatus } = useLoadedIsomorphicClerk(restIsomorphicClerkOptions); + return ( {children} @@ -34,3 +39,28 @@ const ClerkProvider = withMaxAllowedInstancesGuard(ClerkProviderBase, 'ClerkProv ClerkProvider.displayName = 'ClerkProvider'; export { ClerkProvider }; + +const useLoadedIsomorphicClerk = (options: IsomorphicClerkOptions) => { + const isomorphicClerkRef = React.useRef(IsomorphicClerk.getOrCreateInstance(options)); + const [clerkStatus, setClerkStatus] = React.useState(isomorphicClerkRef.current.status); + + React.useEffect(() => { + void isomorphicClerkRef.current.__unstable__updateProps({ appearance: options.appearance }); + }, [options.appearance]); + + React.useEffect(() => { + void isomorphicClerkRef.current.__unstable__updateProps({ options }); + }, [options.localization]); + + React.useEffect(() => { + isomorphicClerkRef.current.on('status', setClerkStatus); + return () => { + if (isomorphicClerkRef.current) { + isomorphicClerkRef.current.off('status', setClerkStatus); + } + IsomorphicClerk.clearInstance(); + }; + }, []); + + return { isomorphicClerk: isomorphicClerkRef.current, clerkStatus }; +}; diff --git a/packages/react/src/contexts/IsomorphicClerkContext.tsx b/packages/react/src/contexts/IsomorphicClerkContext.tsx index 765326db501..7cd10217707 100644 --- a/packages/react/src/contexts/IsomorphicClerkContext.tsx +++ b/packages/react/src/contexts/IsomorphicClerkContext.tsx @@ -1,6 +1,5 @@ -import { ClerkInstanceContext, useClerkInstanceContext } from '@clerk/shared/react'; +import { useClerkInstanceContext } from '@clerk/shared/react'; import type { IsomorphicClerk } from '../isomorphicClerk'; -export const IsomorphicClerkContext = ClerkInstanceContext; export const useIsomorphicClerkContext = useClerkInstanceContext as unknown as () => IsomorphicClerk; diff --git a/packages/react/src/internal.ts b/packages/react/src/internal.ts index 62307ba0b41..4fa353724b6 100644 --- a/packages/react/src/internal.ts +++ b/packages/react/src/internal.ts @@ -10,5 +10,3 @@ export { buildClerkUiScriptAttributes, setClerkJsLoadingErrorPackageName, } from '@clerk/shared/loadClerkJsScript'; - -export { InitialAuthStateProvider } from './contexts/AuthContext'; diff --git a/packages/shared/src/react/ClerkContextProvider.tsx b/packages/shared/src/react/ClerkContextProvider.tsx index 81cbef272e6..e760b1a7caf 100644 --- a/packages/shared/src/react/ClerkContextProvider.tsx +++ b/packages/shared/src/react/ClerkContextProvider.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import type { Clerk, LoadedClerk, Resources } from '../types'; +import { deriveState } from '../deriveState'; +import type { Clerk, ClerkStatus, InitialState, LoadedClerk, Resources } from '../types'; import { __experimental_CheckoutProvider as CheckoutProvider, ClerkInstanceContext, ClientContext, + InitialStateProvider, OrganizationProvider, SessionContext, UserContext, @@ -13,8 +15,10 @@ import { assertClerkSingletonExists } from './utils'; type ClerkContextProps = { clerk: Clerk; + clerkStatus?: ClerkStatus; children: React.ReactNode; swrConfig?: any; + initialState?: InitialState; }; type CoreClerkContextProviderState = Resources; @@ -35,9 +39,18 @@ export function ClerkContextProvider(props: ClerkContextProps): JSX.Element | nu return clerk.addListener(e => setState({ ...e })); }, []); - const { client, session, user, organization } = state; - const clerkCtx = React.useMemo(() => ({ value: clerk }), []); - const clientCtx = React.useMemo(() => ({ value: client }), [client]); + const clerkCtx = React.useMemo( + () => ({ value: clerk }), + // clerkStatus is a way to control the referential integrity of the clerk object from the outside, + // we only change the context value when the status changes. Since clerk is mutable, any read from + // the object will always be the latest value anyway. + [props.clerkStatus], + ); + const clientCtx = React.useMemo(() => ({ value: state.client }), [state.client]); + + const resolvedState = deriveState(clerk.loaded, state, props.initialState); + const { session, user, organization } = resolvedState; + const sessionCtx = React.useMemo(() => ({ value: session }), [session]); const userCtx = React.useMemo(() => ({ value: user }), [user]); const organizationCtx = React.useMemo( @@ -48,24 +61,26 @@ export function ClerkContextProvider(props: ClerkContextProps): JSX.Element | nu ); return ( - - - - - - - {props.children} - - - - - - + + + + + + + + {props.children} + + + + + + + ); } diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index 8e9e11a75c8..ee371b69a11 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -8,6 +8,7 @@ import type { ClerkOptions, ClientResource, ForPayerType, + InitialState, LoadedClerk, OrganizationResource, SignedInSessionResource, @@ -23,6 +24,21 @@ const [SessionContext, useSessionContext] = createContextAndHook( + 'InitialStateContext', +); +export { useInitialStateContext }; +export function InitialStateProvider({ + children, + initialState, +}: { + children: React.ReactNode; + initialState: InitialState | undefined; +}) { + const initialStateCtx = React.useMemo(() => ({ value: initialState }), [initialState]); + return {children}; +} + const OptionsContext = React.createContext({}); /** diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index 4932c9d1fa0..b80b9f89e77 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -15,6 +15,8 @@ export { useSessionContext, useUserContext, __experimental_CheckoutProvider, + InitialStateProvider, + useInitialStateContext, } from './contexts'; export * from './commerce'; From 288cf94cb9e9e7eebf8f1691cf672207f230cebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 20 Nov 2025 10:49:25 +0100 Subject: [PATCH 17/35] Remove SessionContext and refactor to uSES --- packages/react/src/contexts/AuthContext.tsx | 6 ++-- .../react/src/contexts/SessionContext.tsx | 2 +- .../shared/src/react/ClerkContextProvider.tsx | 34 ++++++++---------- packages/shared/src/react/contexts.tsx | 7 +--- .../src/react/hooks/base/useSessionBase.ts | 36 +++++++++++++++++++ packages/shared/src/react/index.ts | 1 - 6 files changed, 55 insertions(+), 31 deletions(-) create mode 100644 packages/shared/src/react/hooks/base/useSessionBase.ts diff --git a/packages/react/src/contexts/AuthContext.tsx b/packages/react/src/contexts/AuthContext.tsx index 6f47542893d..116f640d8ee 100644 --- a/packages/react/src/contexts/AuthContext.tsx +++ b/packages/react/src/contexts/AuthContext.tsx @@ -1,6 +1,6 @@ import type { DeriveStateReturnType } from '@clerk/shared/deriveState'; import { deriveFromClientSideState, deriveFromSsrInitialState } from '@clerk/shared/deriveState'; -import { useInitialStateContext } from '@clerk/shared/react'; +import { useClerkInstanceContext, useInitialStateContext } from '@clerk/shared/react'; import type { ActClaim, ClientResource, @@ -11,8 +11,6 @@ import type { } from '@clerk/shared/types'; import { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react'; -import { useIsomorphicClerkContext } from './IsomorphicClerkContext'; - type AuthStateValue = { userId: string | null | undefined; sessionId: string | null | undefined; @@ -40,7 +38,7 @@ export const defaultDerivedInitialState = { }; export function useAuthState(): AuthStateValue { - const clerk = useIsomorphicClerkContext(); + const clerk = useClerkInstanceContext(); const initialStateContext = useInitialStateContext(); // If we make initialState support a promise in the future, this is where we would use() that promise const initialSnapshot = useMemo(() => { diff --git a/packages/react/src/contexts/SessionContext.tsx b/packages/react/src/contexts/SessionContext.tsx index 4de21025933..c4b5c1d1bd3 100644 --- a/packages/react/src/contexts/SessionContext.tsx +++ b/packages/react/src/contexts/SessionContext.tsx @@ -1 +1 @@ -export { SessionContext, useSessionContext } from '@clerk/shared/react'; +export { useSessionContext } from '@clerk/shared/react'; diff --git a/packages/shared/src/react/ClerkContextProvider.tsx b/packages/shared/src/react/ClerkContextProvider.tsx index e760b1a7caf..ef28f932c2c 100644 --- a/packages/shared/src/react/ClerkContextProvider.tsx +++ b/packages/shared/src/react/ClerkContextProvider.tsx @@ -8,7 +8,6 @@ import { ClientContext, InitialStateProvider, OrganizationProvider, - SessionContext, UserContext, } from './contexts'; import { assertClerkSingletonExists } from './utils'; @@ -46,12 +45,11 @@ export function ClerkContextProvider(props: ClerkContextProps): JSX.Element | nu // the object will always be the latest value anyway. [props.clerkStatus], ); + // TODO: I believe this is not always defined with isomorphic clerk, need to think on that const clientCtx = React.useMemo(() => ({ value: state.client }), [state.client]); const resolvedState = deriveState(clerk.loaded, state, props.initialState); - const { session, user, organization } = resolvedState; - - const sessionCtx = React.useMemo(() => ({ value: session }), [session]); + const { user, organization } = resolvedState; const userCtx = React.useMemo(() => ({ value: user }), [user]); const organizationCtx = React.useMemo( () => ({ @@ -64,21 +62,19 @@ export function ClerkContextProvider(props: ClerkContextProps): JSX.Element | nu - - - - - {props.children} - - - - + + + + {props.children} + + + diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index ee371b69a11..31a6518fe63 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -11,7 +11,6 @@ import type { InitialState, LoadedClerk, OrganizationResource, - SignedInSessionResource, UserResource, } from '../types'; import { createContextAndHook } from './hooks/createContextAndHook'; @@ -20,9 +19,7 @@ import { SWRConfigCompat } from './providers/SWRConfigCompat'; const [ClerkInstanceContext, useClerkInstanceContext] = createContextAndHook('ClerkInstanceContext'); const [UserContext, useUserContext] = createContextAndHook('UserContext'); const [ClientContext, useClientContext] = createContextAndHook('ClientContext'); -const [SessionContext, useSessionContext] = createContextAndHook( - 'SessionContext', -); +export { useSessionBase as useSessionContext } from './hooks/base/useSessionBase'; const [InitialStateContext, useInitialStateContext] = createContextAndHook( 'InitialStateContext', @@ -138,7 +135,6 @@ export { ClientContext, OptionsContext, OrganizationProvider, - SessionContext, useAssertWrappedByClerkProvider, useCheckoutContext, useClerkInstanceContext, @@ -146,6 +142,5 @@ export { useOptionsContext, useOrganizationContext, UserContext, - useSessionContext, useUserContext, }; diff --git a/packages/shared/src/react/hooks/base/useSessionBase.ts b/packages/shared/src/react/hooks/base/useSessionBase.ts new file mode 100644 index 00000000000..256445a236b --- /dev/null +++ b/packages/shared/src/react/hooks/base/useSessionBase.ts @@ -0,0 +1,36 @@ +import { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react'; + +import type { SignedInSessionResource } from '@/types'; + +import { useClerkInstanceContext, useInitialStateContext } from '../../contexts'; + +export function useSessionBase(): SignedInSessionResource | null | undefined { + const clerk = useClerkInstanceContext(); + const initialStateContext = useInitialStateContext(); + // If we make initialState support a promise in the future, this is where we would use() that promise + const initialSnapshot = useMemo(() => { + if (!initialStateContext) { + return undefined; + } + return initialStateContext.session as SignedInSessionResource; + }, [initialStateContext]); + + const snapshot = useMemo(() => { + if (!clerk.loaded) { + return initialSnapshot; + } + return clerk.session; + }, [clerk.session, initialSnapshot, clerk.loaded]); + + const session = useSyncExternalStore( + clerk.addListener, + useCallback(() => snapshot, [snapshot]), + useCallback(() => initialSnapshot, [initialSnapshot]), + ); + + // If an updates comes in during a transition, uSES usually deopts that transition to be synchronous, + // which for example means that already mounted boundaries might suddenly show their fallback. + // This makes all auth state changes into transitions, but does not deopt to be synchronous. If it's + // called during a transition, it immediately uses the new value without deferring. + return useDeferredValue(session); +} diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index b80b9f89e77..27cf01e224d 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -5,7 +5,6 @@ export { ClientContext, OptionsContext, OrganizationProvider, - SessionContext, useAssertWrappedByClerkProvider, useClerkInstanceContext, useClientContext, From 5d878952d84d7c9f3e24e6b512cb8e3a2b6b748e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 20 Nov 2025 10:56:12 +0100 Subject: [PATCH 18/35] Remove UserContext and refactor to uSES --- packages/react/src/contexts/UserContext.tsx | 2 +- .../shared/src/react/ClerkContextProvider.tsx | 18 ++++------ packages/shared/src/react/contexts.tsx | 5 +-- .../src/react/hooks/base/useUserBase.ts | 36 +++++++++++++++++++ packages/shared/src/react/index.ts | 1 - 5 files changed, 45 insertions(+), 17 deletions(-) create mode 100644 packages/shared/src/react/hooks/base/useUserBase.ts diff --git a/packages/react/src/contexts/UserContext.tsx b/packages/react/src/contexts/UserContext.tsx index c5ef71321e0..24c6fb4ab94 100644 --- a/packages/react/src/contexts/UserContext.tsx +++ b/packages/react/src/contexts/UserContext.tsx @@ -1 +1 @@ -export { UserContext, useUserContext } from '@clerk/shared/react'; +export { useUserContext } from '@clerk/shared/react'; diff --git a/packages/shared/src/react/ClerkContextProvider.tsx b/packages/shared/src/react/ClerkContextProvider.tsx index ef28f932c2c..63ce94eee81 100644 --- a/packages/shared/src/react/ClerkContextProvider.tsx +++ b/packages/shared/src/react/ClerkContextProvider.tsx @@ -8,7 +8,6 @@ import { ClientContext, InitialStateProvider, OrganizationProvider, - UserContext, } from './contexts'; import { assertClerkSingletonExists } from './utils'; @@ -49,8 +48,7 @@ export function ClerkContextProvider(props: ClerkContextProps): JSX.Element | nu const clientCtx = React.useMemo(() => ({ value: state.client }), [state.client]); const resolvedState = deriveState(clerk.loaded, state, props.initialState); - const { user, organization } = resolvedState; - const userCtx = React.useMemo(() => ({ value: user }), [user]); + const { organization } = resolvedState; const organizationCtx = React.useMemo( () => ({ value: { organization: organization }, @@ -66,14 +64,12 @@ export function ClerkContextProvider(props: ClerkContextProps): JSX.Element | nu {...organizationCtx.value} swrConfig={props.swrConfig} > - - - {props.children} - - + + {props.children} + diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index 31a6518fe63..944b0850bf2 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -11,13 +11,12 @@ import type { InitialState, LoadedClerk, OrganizationResource, - UserResource, } from '../types'; import { createContextAndHook } from './hooks/createContextAndHook'; import { SWRConfigCompat } from './providers/SWRConfigCompat'; const [ClerkInstanceContext, useClerkInstanceContext] = createContextAndHook('ClerkInstanceContext'); -const [UserContext, useUserContext] = createContextAndHook('UserContext'); +export { useUserBase as useUserContext } from './hooks/base/useUserBase'; const [ClientContext, useClientContext] = createContextAndHook('ClientContext'); export { useSessionBase as useSessionContext } from './hooks/base/useSessionBase'; @@ -141,6 +140,4 @@ export { useClientContext, useOptionsContext, useOrganizationContext, - UserContext, - useUserContext, }; diff --git a/packages/shared/src/react/hooks/base/useUserBase.ts b/packages/shared/src/react/hooks/base/useUserBase.ts new file mode 100644 index 00000000000..539cd031ecb --- /dev/null +++ b/packages/shared/src/react/hooks/base/useUserBase.ts @@ -0,0 +1,36 @@ +import { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react'; + +import type { UserResource } from '@/types'; + +import { useClerkInstanceContext, useInitialStateContext } from '../../contexts'; + +export function useUserBase(): UserResource | null | undefined { + const clerk = useClerkInstanceContext(); + const initialStateContext = useInitialStateContext(); + // If we make initialState support a promise in the future, this is where we would use() that promise + const initialSnapshot = useMemo(() => { + if (!initialStateContext) { + return undefined; + } + return initialStateContext.user; + }, [initialStateContext]); + + const snapshot = useMemo(() => { + if (!clerk.loaded) { + return initialSnapshot; + } + return clerk.user; + }, [clerk.user, initialSnapshot, clerk.loaded]); + + const user = useSyncExternalStore( + clerk.addListener, + useCallback(() => snapshot, [snapshot]), + useCallback(() => initialSnapshot, [initialSnapshot]), + ); + + // If an updates comes in during a transition, uSES usually deopts that transition to be synchronous, + // which for example means that already mounted boundaries might suddenly show their fallback. + // This makes all auth state changes into transitions, but does not deopt to be synchronous. If it's + // called during a transition, it immediately uses the new value without deferring. + return useDeferredValue(user); +} diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index 27cf01e224d..81574d4a553 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -10,7 +10,6 @@ export { useClientContext, useOptionsContext, useOrganizationContext, - UserContext, useSessionContext, useUserContext, __experimental_CheckoutProvider, From a8f9f60cfa0bd9a91ae6b153a96210137dd1b9ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 20 Nov 2025 11:09:21 +0100 Subject: [PATCH 19/35] Remove OrganizationProvider and refactor to uSES --- .../src/contexts/OrganizationContext.tsx | 2 +- .../shared/src/react/ClerkContextProvider.tsx | 32 ++++------------- packages/shared/src/react/contexts.tsx | 36 +++---------------- .../react/hooks/base/useOrganizationBase.ts | 36 +++++++++++++++++++ packages/shared/src/react/index.ts | 1 - 5 files changed, 48 insertions(+), 59 deletions(-) create mode 100644 packages/shared/src/react/hooks/base/useOrganizationBase.ts diff --git a/packages/react/src/contexts/OrganizationContext.tsx b/packages/react/src/contexts/OrganizationContext.tsx index 099dc09105a..2661fbdfc28 100644 --- a/packages/react/src/contexts/OrganizationContext.tsx +++ b/packages/react/src/contexts/OrganizationContext.tsx @@ -1 +1 @@ -export { OrganizationProvider, useOrganizationContext } from '@clerk/shared/react'; +export { useOrganizationContext } from '@clerk/shared/react'; diff --git a/packages/shared/src/react/ClerkContextProvider.tsx b/packages/shared/src/react/ClerkContextProvider.tsx index 63ce94eee81..305f93c8f28 100644 --- a/packages/shared/src/react/ClerkContextProvider.tsx +++ b/packages/shared/src/react/ClerkContextProvider.tsx @@ -1,14 +1,13 @@ import React from 'react'; -import { deriveState } from '../deriveState'; import type { Clerk, ClerkStatus, InitialState, LoadedClerk, Resources } from '../types'; import { __experimental_CheckoutProvider as CheckoutProvider, ClerkInstanceContext, ClientContext, InitialStateProvider, - OrganizationProvider, } from './contexts'; +import { SWRConfigCompat } from './providers/SWRConfigCompat'; import { assertClerkSingletonExists } from './utils'; type ClerkContextProps = { @@ -19,22 +18,15 @@ type ClerkContextProps = { initialState?: InitialState; }; -type CoreClerkContextProviderState = Resources; - export function ClerkContextProvider(props: ClerkContextProps): JSX.Element | null { const clerk = props.clerk as LoadedClerk; assertClerkSingletonExists(clerk); - const [state, setState] = React.useState({ - client: clerk.client, - session: clerk.session, - user: clerk.user, - organization: clerk.organization, - }); + const [client, setClient] = React.useState(clerk.client); React.useEffect(() => { - return clerk.addListener(e => setState({ ...e })); + return clerk.addListener(e => setClient(e.client)); }, []); const clerkCtx = React.useMemo( @@ -45,32 +37,20 @@ export function ClerkContextProvider(props: ClerkContextProps): JSX.Element | nu [props.clerkStatus], ); // TODO: I believe this is not always defined with isomorphic clerk, need to think on that - const clientCtx = React.useMemo(() => ({ value: state.client }), [state.client]); - - const resolvedState = deriveState(clerk.loaded, state, props.initialState); - const { organization } = resolvedState; - const organizationCtx = React.useMemo( - () => ({ - value: { organization: organization }, - }), - [organization], - ); + const clientCtx = React.useMemo(() => ({ value: client }), [client]); return ( - + {props.children} - + diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index 944b0850bf2..5f75b0673a5 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -12,8 +12,8 @@ import type { LoadedClerk, OrganizationResource, } from '../types'; +import { useOrganizationBase } from './hooks/base/useOrganizationBase'; import { createContextAndHook } from './hooks/createContextAndHook'; -import { SWRConfigCompat } from './providers/SWRConfigCompat'; const [ClerkInstanceContext, useClerkInstanceContext] = createContextAndHook('ClerkInstanceContext'); export { useUserBase as useUserContext } from './hooks/base/useUserBase'; @@ -74,35 +74,10 @@ function useOptionsContext(): ClerkOptions { return context; } -type OrganizationContextProps = { - organization: OrganizationResource | null | undefined; -}; -const [OrganizationContextInternal, useOrganizationContext] = createContextAndHook<{ - organization: OrganizationResource | null | undefined; -}>('OrganizationContext'); - -const OrganizationProvider = ({ - children, - organization, - swrConfig, -}: PropsWithChildren< - OrganizationContextProps & { - // Exporting inferred types directly from SWR will result in error while building declarations - swrConfig?: any; - } ->) => { - return ( - - - {children} - - - ); -}; +function useOrganizationContext(): { organization: OrganizationResource | null | undefined } { + const organization = useOrganizationBase(); + return React.useMemo(() => ({ organization }), [organization]); +} /** * @internal @@ -133,7 +108,6 @@ export { ClerkInstanceContext, ClientContext, OptionsContext, - OrganizationProvider, useAssertWrappedByClerkProvider, useCheckoutContext, useClerkInstanceContext, diff --git a/packages/shared/src/react/hooks/base/useOrganizationBase.ts b/packages/shared/src/react/hooks/base/useOrganizationBase.ts new file mode 100644 index 00000000000..e97c5fcd650 --- /dev/null +++ b/packages/shared/src/react/hooks/base/useOrganizationBase.ts @@ -0,0 +1,36 @@ +import { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react'; + +import type { OrganizationResource } from '@/types'; + +import { useClerkInstanceContext, useInitialStateContext } from '../../contexts'; + +export function useOrganizationBase(): OrganizationResource | null | undefined { + const clerk = useClerkInstanceContext(); + const initialStateContext = useInitialStateContext(); + // If we make initialState support a promise in the future, this is where we would use() that promise + const initialSnapshot = useMemo(() => { + if (!initialStateContext) { + return undefined; + } + return initialStateContext.organization; + }, [initialStateContext]); + + const snapshot = useMemo(() => { + if (!clerk.loaded) { + return initialSnapshot; + } + return clerk.organization; + }, [clerk.organization, initialSnapshot, clerk.loaded]); + + const organization = useSyncExternalStore( + clerk.addListener, + useCallback(() => snapshot, [snapshot]), + useCallback(() => initialSnapshot, [initialSnapshot]), + ); + + // If an updates comes in during a transition, uSES usually deopts that transition to be synchronous, + // which for example means that already mounted boundaries might suddenly show their fallback. + // This makes all auth state changes into transitions, but does not deopt to be synchronous. If it's + // called during a transition, it immediately uses the new value without deferring. + return useDeferredValue(organization); +} diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index 81574d4a553..eaf8ba6da2a 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -4,7 +4,6 @@ export { ClerkInstanceContext, ClientContext, OptionsContext, - OrganizationProvider, useAssertWrappedByClerkProvider, useClerkInstanceContext, useClientContext, From f37d8d137db3422fa6be08e9171812ed94a621df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 20 Nov 2025 11:14:52 +0100 Subject: [PATCH 20/35] Remove ClientContext and refactor to uSES --- .../shared/src/react/ClerkContextProvider.tsx | 29 ++++++------------- packages/shared/src/react/contexts.tsx | 5 +--- .../src/react/hooks/base/useClientBase.ts | 29 +++++++++++++++++++ packages/shared/src/react/index.ts | 1 - .../ui/src/contexts/CoreClientContext.tsx | 8 +++-- 5 files changed, 44 insertions(+), 28 deletions(-) create mode 100644 packages/shared/src/react/hooks/base/useClientBase.ts diff --git a/packages/shared/src/react/ClerkContextProvider.tsx b/packages/shared/src/react/ClerkContextProvider.tsx index 305f93c8f28..05f325c6acc 100644 --- a/packages/shared/src/react/ClerkContextProvider.tsx +++ b/packages/shared/src/react/ClerkContextProvider.tsx @@ -1,10 +1,9 @@ import React from 'react'; -import type { Clerk, ClerkStatus, InitialState, LoadedClerk, Resources } from '../types'; +import type { Clerk, ClerkStatus, InitialState, LoadedClerk } from '../types'; import { __experimental_CheckoutProvider as CheckoutProvider, ClerkInstanceContext, - ClientContext, InitialStateProvider, } from './contexts'; import { SWRConfigCompat } from './providers/SWRConfigCompat'; @@ -23,12 +22,6 @@ export function ClerkContextProvider(props: ClerkContextProps): JSX.Element | nu assertClerkSingletonExists(clerk); - const [client, setClient] = React.useState(clerk.client); - - React.useEffect(() => { - return clerk.addListener(e => setClient(e.client)); - }, []); - const clerkCtx = React.useMemo( () => ({ value: clerk }), // clerkStatus is a way to control the referential integrity of the clerk object from the outside, @@ -36,22 +29,18 @@ export function ClerkContextProvider(props: ClerkContextProps): JSX.Element | nu // the object will always be the latest value anyway. [props.clerkStatus], ); - // TODO: I believe this is not always defined with isomorphic clerk, need to think on that - const clientCtx = React.useMemo(() => ({ value: client }), [client]); return ( - - - - {props.children} - - - + + + {props.children} + + ); diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index 5f75b0673a5..0d355b833f0 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -6,7 +6,6 @@ import React from 'react'; import type { BillingSubscriptionPlanPeriod, ClerkOptions, - ClientResource, ForPayerType, InitialState, LoadedClerk, @@ -17,7 +16,7 @@ import { createContextAndHook } from './hooks/createContextAndHook'; const [ClerkInstanceContext, useClerkInstanceContext] = createContextAndHook('ClerkInstanceContext'); export { useUserBase as useUserContext } from './hooks/base/useUserBase'; -const [ClientContext, useClientContext] = createContextAndHook('ClientContext'); +export { useClientBase as useClientContext } from './hooks/base/useClientBase'; export { useSessionBase as useSessionContext } from './hooks/base/useSessionBase'; const [InitialStateContext, useInitialStateContext] = createContextAndHook( @@ -106,12 +105,10 @@ Learn more: https://clerk.com/docs/components/clerk-provider`.trim(), export { __experimental_CheckoutProvider, ClerkInstanceContext, - ClientContext, OptionsContext, useAssertWrappedByClerkProvider, useCheckoutContext, useClerkInstanceContext, - useClientContext, useOptionsContext, useOrganizationContext, }; diff --git a/packages/shared/src/react/hooks/base/useClientBase.ts b/packages/shared/src/react/hooks/base/useClientBase.ts new file mode 100644 index 00000000000..7e739b80e57 --- /dev/null +++ b/packages/shared/src/react/hooks/base/useClientBase.ts @@ -0,0 +1,29 @@ +import { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react'; + +import type { ClientResource } from '@/types'; + +import { useClerkInstanceContext } from '../../contexts'; + +const initialSnapshot = undefined; +export function useClientBase(): ClientResource | null | undefined { + const clerk = useClerkInstanceContext(); + + const snapshot = useMemo(() => { + if (!clerk.loaded) { + return initialSnapshot; + } + return clerk.client; + }, [clerk.client, clerk.loaded]); + + const client = useSyncExternalStore( + clerk.addListener, + useCallback(() => snapshot, [snapshot]), + useCallback(() => initialSnapshot, []), + ); + + // If an updates comes in during a transition, uSES usually deopts that transition to be synchronous, + // which for example means that already mounted boundaries might suddenly show their fallback. + // This makes all auth state changes into transitions, but does not deopt to be synchronous. If it's + // called during a transition, it immediately uses the new value without deferring. + return useDeferredValue(client); +} diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index eaf8ba6da2a..a6d84714b00 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -2,7 +2,6 @@ export * from './hooks'; export { ClerkInstanceContext, - ClientContext, OptionsContext, useAssertWrappedByClerkProvider, useClerkInstanceContext, diff --git a/packages/ui/src/contexts/CoreClientContext.tsx b/packages/ui/src/contexts/CoreClientContext.tsx index 5f22e8bc3a9..aa7979efaae 100644 --- a/packages/ui/src/contexts/CoreClientContext.tsx +++ b/packages/ui/src/contexts/CoreClientContext.tsx @@ -1,14 +1,16 @@ -import { assertContextExists, ClientContext, useClientContext } from '@clerk/shared/react'; +import { assertContextExists, useClientContext } from '@clerk/shared/react'; import type { SignInResource, SignUpResource } from '@clerk/shared/types'; export function useCoreSignIn(): SignInResource { const ctx = useClientContext(); - assertContextExists(ctx, ClientContext); + // TODO: useClientContext doesn't actually rely on a context anymore, so we should update this message + assertContextExists(ctx, 'ClientContext'); return ctx.signIn; } export function useCoreSignUp(): SignUpResource { const ctx = useClientContext(); - assertContextExists(ctx, ClientContext); + // TODO: useClientContext doesn't actually rely on a context anymore, so we should update this message + assertContextExists(ctx, 'ClientContext'); return ctx.signUp; } From d3d85d1a136d11d34ded99503f3eaa5f1e179a91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 20 Nov 2025 13:29:50 +0100 Subject: [PATCH 21/35] Support passing in initialState as a promise --- .../src/app-router/server/ClerkProvider.tsx | 18 +++++---------- .../app-router/server/keyless-provider.tsx | 9 +------- packages/react/src/types.ts | 2 +- .../shared/src/react/ClerkContextProvider.tsx | 2 +- packages/shared/src/react/contexts.tsx | 22 +++++++++++++++---- 5 files changed, 26 insertions(+), 27 deletions(-) diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index 81b393983be..f40061cce4a 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -1,4 +1,4 @@ -import type { Without } from '@clerk/shared/types'; +import type { InitialState, Without } from '@clerk/shared/types'; import { headers } from 'next/headers'; import React from 'react'; @@ -30,11 +30,13 @@ export async function ClerkProvider( ) { const { children, dynamic, ...rest } = props; - const statePromiseOrValue = dynamic ? getDynamicClerkState() : null; + const statePromiseOrValue = dynamic ? getDynamicClerkState() : undefined; const noncePromiseOrValue = dynamic ? getNonceHeaders() : ''; const propsWithEnvs = mergeNextClerkPropsWithEnv({ ...rest, + initialState: statePromiseOrValue as InitialState | Promise | undefined, + nonce: await noncePromiseOrValue, }); const { shouldRunAsKeyless, runningWithClaimedKeys } = await getKeylessStatus(propsWithEnvs); @@ -52,8 +54,6 @@ export async function ClerkProvider( return ( {children} @@ -61,13 +61,5 @@ export async function ClerkProvider( ); } - return ( - - {children} - - ); + return {children}; } diff --git a/packages/nextjs/src/app-router/server/keyless-provider.tsx b/packages/nextjs/src/app-router/server/keyless-provider.tsx index b559bf20acb..d7ec0d51d0c 100644 --- a/packages/nextjs/src/app-router/server/keyless-provider.tsx +++ b/packages/nextjs/src/app-router/server/keyless-provider.tsx @@ -1,4 +1,3 @@ -import type { AuthObject } from '@clerk/backend'; import type { Without } from '@clerk/shared/types'; import { headers } from 'next/headers'; import type { PropsWithChildren } from 'react'; @@ -35,12 +34,10 @@ export async function getKeylessStatus( type KeylessProviderProps = PropsWithChildren<{ rest: Without; runningWithClaimedKeys: boolean; - initialState: AuthObject | null; - nonce: string; }>; export const KeylessProvider = async (props: KeylessProviderProps) => { - const { rest, runningWithClaimedKeys, initialState, nonce, children } = props; + const { rest, runningWithClaimedKeys, children } = props; // NOTE: Create or read keys on every render. Usually this means only on hard refresh or hard navigations. const newOrReadKeys = await import('../../server/keyless-node.js') @@ -56,8 +53,6 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { return ( {children} @@ -75,8 +70,6 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { // Explicitly use `null` instead of `undefined` here to avoid persisting `deleteKeylessAction` during merging of options. __internal_keyless_dismissPrompt: runningWithClaimedKeys ? deleteKeylessAction : null, })} - nonce={nonce} - initialState={initialState} > {children} diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 2a2dfc939bb..1fd2474cafd 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -40,7 +40,7 @@ export type ClerkProviderProps = IsomorphicClerkOptions & { /** * Provide an initial state of the Clerk client during server-side rendering. You don't need to set this value yourself unless you're [developing an SDK](https://clerk.com/docs/guides/development/sdk-development/overview). */ - initialState?: InitialState; + initialState?: InitialState | Promise; /** * Indicates to silently fail the initialization process when the publishable keys is not provided, instead of throwing an error. * @default false diff --git a/packages/shared/src/react/ClerkContextProvider.tsx b/packages/shared/src/react/ClerkContextProvider.tsx index 05f325c6acc..a4dc0871d2d 100644 --- a/packages/shared/src/react/ClerkContextProvider.tsx +++ b/packages/shared/src/react/ClerkContextProvider.tsx @@ -14,7 +14,7 @@ type ClerkContextProps = { clerkStatus?: ClerkStatus; children: React.ReactNode; swrConfig?: any; - initialState?: InitialState; + initialState?: InitialState | Promise; }; export function ClerkContextProvider(props: ClerkContextProps): JSX.Element | null { diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index 0d355b833f0..a32345355dc 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -19,20 +19,34 @@ export { useUserBase as useUserContext } from './hooks/base/useUserBase'; export { useClientBase as useClientContext } from './hooks/base/useClientBase'; export { useSessionBase as useSessionContext } from './hooks/base/useSessionBase'; -const [InitialStateContext, useInitialStateContext] = createContextAndHook( - 'InitialStateContext', -); +const [InitialStateContext, _useInitialStateContext] = createContextAndHook< + InitialState | Promise | undefined +>('InitialStateContext'); export { useInitialStateContext }; export function InitialStateProvider({ children, initialState, }: { children: React.ReactNode; - initialState: InitialState | undefined; + initialState: InitialState | Promise | undefined; }) { const initialStateCtx = React.useMemo(() => ({ value: initialState }), [initialState]); return {children}; } +function useInitialStateContext(): InitialState | undefined { + const initialState = _useInitialStateContext(); + + if (!initialState) { + return undefined; + } + + if (initialState && 'then' in initialState) { + // TODO: If we want to preserve backwards compatibility, we'd need to throw here instead + // @ts-expect-error See above + return React.use(initialState); + } + return initialState; +} const OptionsContext = React.createContext({}); From 5b819122eef0149d1185640923b03975346bb83a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 20 Nov 2025 14:36:56 +0100 Subject: [PATCH 22/35] Add skipInitialEmit option to addListener and use in uSES --- packages/clerk-js/src/core/clerk.ts | 5 ++-- packages/react/src/contexts/AuthContext.tsx | 2 +- packages/react/src/isomorphicClerk.ts | 24 +++++++++++-------- .../src/react/hooks/base/useClientBase.ts | 2 +- .../react/hooks/base/useOrganizationBase.ts | 2 +- .../src/react/hooks/base/useSessionBase.ts | 2 +- .../src/react/hooks/base/useUserBase.ts | 2 +- packages/shared/src/types/clerk.ts | 3 ++- 8 files changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 63c7e8690e6..4cdc88edff9 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -80,6 +80,7 @@ import type { InstanceType, JoinWaitlistParams, ListenerCallback, + ListenerOptions, NavigateOptions, OrganizationListProps, OrganizationProfileProps, @@ -1476,11 +1477,11 @@ export class Clerk implements ClerkInterface { } }; - public addListener = (listener: ListenerCallback): UnsubscribeCallback => { + public addListener = (listener: ListenerCallback, options?: ListenerOptions): UnsubscribeCallback => { listener = memoizeListenerCallback(listener); this.#listeners.push(listener); // emit right away - if (this.client) { + if (this.client && !options?.skipInitialEmit) { listener({ client: this.client, session: this.session, diff --git a/packages/react/src/contexts/AuthContext.tsx b/packages/react/src/contexts/AuthContext.tsx index 116f640d8ee..ac26fb7db9d 100644 --- a/packages/react/src/contexts/AuthContext.tsx +++ b/packages/react/src/contexts/AuthContext.tsx @@ -64,7 +64,7 @@ export function useAuthState(): AuthStateValue { }, [clerk.client, clerk.session, clerk.user, clerk.organization, initialSnapshot, clerk.loaded]); const authState = useSyncExternalStore( - clerk.addListener, + useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), useCallback(() => snapshot, [snapshot]), useCallback(() => initialSnapshot, [initialSnapshot]), ); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index e868f13de9d..ce00d0f3908 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -29,6 +29,7 @@ import type { HandleOAuthCallbackParams, JoinWaitlistParams, ListenerCallback, + ListenerOptions, LoadedClerk, OrganizationListProps, OrganizationProfileProps, @@ -153,8 +154,11 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private premountAddListenerCalls = new Map< ListenerCallback, { - unsubscribe: UnsubscribeCallback; - nativeUnsubscribe?: UnsubscribeCallback; + options?: ListenerOptions; + handlers: { + unsubscribe: UnsubscribeCallback; + nativeUnsubscribe?: UnsubscribeCallback; + }; } >(); private loadedListeners: Array<() => void> = []; @@ -575,8 +579,8 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { this.clerkjs = clerkjs; this.premountMethodCalls.forEach(cb => cb()); - this.premountAddListenerCalls.forEach((listenerHandlers, listener) => { - listenerHandlers.nativeUnsubscribe = clerkjs.addListener(listener); + this.premountAddListenerCalls.forEach((listenerExtras, listener) => { + listenerExtras.handlers.nativeUnsubscribe = clerkjs.addListener(listener, listenerExtras.options); }); this.#eventBus.internal.retrieveListeners('status')?.forEach(listener => { @@ -1207,18 +1211,18 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; - addListener = (listener: ListenerCallback): UnsubscribeCallback => { + addListener = (listener: ListenerCallback, options?: ListenerOptions): UnsubscribeCallback => { if (this.clerkjs) { - return this.clerkjs.addListener(listener); + return this.clerkjs.addListener(listener, options); } else { const unsubscribe = () => { - const listenerHandlers = this.premountAddListenerCalls.get(listener); - if (listenerHandlers) { - listenerHandlers.nativeUnsubscribe?.(); + const listenerExtras = this.premountAddListenerCalls.get(listener); + if (listenerExtras?.handlers) { + listenerExtras?.handlers.nativeUnsubscribe?.(); this.premountAddListenerCalls.delete(listener); } }; - this.premountAddListenerCalls.set(listener, { unsubscribe, nativeUnsubscribe: undefined }); + this.premountAddListenerCalls.set(listener, { options, handlers: { unsubscribe, nativeUnsubscribe: undefined } }); return unsubscribe; } }; diff --git a/packages/shared/src/react/hooks/base/useClientBase.ts b/packages/shared/src/react/hooks/base/useClientBase.ts index 7e739b80e57..5a2b3427a71 100644 --- a/packages/shared/src/react/hooks/base/useClientBase.ts +++ b/packages/shared/src/react/hooks/base/useClientBase.ts @@ -16,7 +16,7 @@ export function useClientBase(): ClientResource | null | undefined { }, [clerk.client, clerk.loaded]); const client = useSyncExternalStore( - clerk.addListener, + useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), useCallback(() => snapshot, [snapshot]), useCallback(() => initialSnapshot, []), ); diff --git a/packages/shared/src/react/hooks/base/useOrganizationBase.ts b/packages/shared/src/react/hooks/base/useOrganizationBase.ts index e97c5fcd650..04a464b7149 100644 --- a/packages/shared/src/react/hooks/base/useOrganizationBase.ts +++ b/packages/shared/src/react/hooks/base/useOrganizationBase.ts @@ -23,7 +23,7 @@ export function useOrganizationBase(): OrganizationResource | null | undefined { }, [clerk.organization, initialSnapshot, clerk.loaded]); const organization = useSyncExternalStore( - clerk.addListener, + useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), useCallback(() => snapshot, [snapshot]), useCallback(() => initialSnapshot, [initialSnapshot]), ); diff --git a/packages/shared/src/react/hooks/base/useSessionBase.ts b/packages/shared/src/react/hooks/base/useSessionBase.ts index 256445a236b..7232573e65a 100644 --- a/packages/shared/src/react/hooks/base/useSessionBase.ts +++ b/packages/shared/src/react/hooks/base/useSessionBase.ts @@ -23,7 +23,7 @@ export function useSessionBase(): SignedInSessionResource | null | undefined { }, [clerk.session, initialSnapshot, clerk.loaded]); const session = useSyncExternalStore( - clerk.addListener, + useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), useCallback(() => snapshot, [snapshot]), useCallback(() => initialSnapshot, [initialSnapshot]), ); diff --git a/packages/shared/src/react/hooks/base/useUserBase.ts b/packages/shared/src/react/hooks/base/useUserBase.ts index 539cd031ecb..765336257cf 100644 --- a/packages/shared/src/react/hooks/base/useUserBase.ts +++ b/packages/shared/src/react/hooks/base/useUserBase.ts @@ -23,7 +23,7 @@ export function useUserBase(): UserResource | null | undefined { }, [clerk.user, initialSnapshot, clerk.loaded]); const user = useSyncExternalStore( - clerk.addListener, + useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), useCallback(() => snapshot, [snapshot]), useCallback(() => initialSnapshot, [initialSnapshot]), ); diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index c2f385cef37..99c4092da9f 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -139,6 +139,7 @@ export type SDKMetadata = { }; export type ListenerCallback = (emission: Resources) => void; +export type ListenerOptions = { skipInitialEmit?: boolean }; export type UnsubscribeCallback = () => void; export type BeforeEmitCallback = (session?: SignedInSessionResource | null) => void | Promise; export type SetActiveNavigate = ({ session }: { session: SessionResource }) => void | Promise; @@ -662,7 +663,7 @@ export interface Clerk { * @param callback - Callback function receiving the most updated Clerk resources after a change. * @returns - Unsubscribe callback */ - addListener: (callback: ListenerCallback) => UnsubscribeCallback; + addListener: (callback: ListenerCallback, options?: ListenerOptions) => UnsubscribeCallback; /** * Registers an event handler for a specific Clerk event. From c3c79f99232457be5e113bdeb216e91cdbaec006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 20 Nov 2025 14:44:50 +0100 Subject: [PATCH 23/35] Remove unrelated changeset --- .changeset/dry-bobcats-drop.md | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 .changeset/dry-bobcats-drop.md diff --git a/.changeset/dry-bobcats-drop.md b/.changeset/dry-bobcats-drop.md deleted file mode 100644 index d54364aec78..00000000000 --- a/.changeset/dry-bobcats-drop.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@clerk/react': major -'@clerk/expo': major -'@clerk/nextjs': major -'@clerk/react-router': major -'@clerk/tanstack-react-start': major -'@clerk/chrome-extension': patch -'@clerk/elements': patch ---- - -Remove `initialAuthState` option from `useAuth` hook. - -This option was mainly used internally but is no longer necessary, so we are removing it to keep the API simple. - -If you want `useAuth` to return a populated auth state before Clerk has fully loaded, for example during server rendering, see your framework-specific documentation for guidance. From 7581b74ec82477a0d61ff3aa82a6e12a6bfd3570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 26 Nov 2025 10:29:03 +0100 Subject: [PATCH 24/35] Rename setAccessors -> updateAccessors and make it emit --- packages/clerk-js/src/core/clerk.ts | 60 +++++++++++++++++++---------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 78b23ab8d3b..c72e1b6551c 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -491,8 +491,7 @@ export class Clerk implements ClerkInterface { * with the new token to the state listeners. */ eventBus.on(events.SessionTokenResolved, () => { - this.#setAccessors(this.session); - this.#emit(); + this.#updateAccessors(this.session); }); if (this.#options.sdkMetadata) { @@ -601,8 +600,7 @@ export class Clerk implements ClerkInterface { return; } - this.#setAccessors(); - this.#emit(); + this.#updateAccessors(); await onAfterSetActive(); }; @@ -1449,8 +1447,7 @@ export class Clerk implements ClerkInterface { return; } - this.#setAccessors(newSession); - this.#emit(); + this.#updateAccessors(newSession); // Do not revalidate server cache for pending sessions to avoid unmount of `SignIn/SignUp` AIOs when navigating to task // newSession can be mutated by the time we get here (org change session touch) @@ -2365,14 +2362,16 @@ export class Clerk implements ClerkInterface { const session = this.#options.selectInitialSession ? this.#options.selectInitialSession(newClient) : this.#defaultSession(newClient); - this.#setAccessors(session); + + this.#updateAccessors(session, { dangerouslySkipEmit: true }); } + this.client = newClient; if (this.session) { - const session = this.#getSessionFromClient(this.session.id); + const newSession = this.#getSessionFromClient(this.session.id, newClient); - const hasTransitionedToPendingStatus = this.session.status === 'active' && session?.status === 'pending'; + const hasTransitionedToPendingStatus = this.session.status === 'active' && newSession?.status === 'pending'; if (hasTransitionedToPendingStatus) { const onAfterSetActive: SetActiveHook = typeof window !== 'undefined' && typeof window.__unstable__onAfterSetActive === 'function' @@ -2385,7 +2384,10 @@ export class Clerk implements ClerkInterface { } // Note: this might set this.session to null - this.#setAccessors(session); + // We need to set these values before emitting the token update, as handling that relies on these values being set. + // We don't want to emit here though, as we want to emit the token update first. That happens synchronously, so it + // should be safe as long as we call #emit() right after. + this.#updateAccessors(newSession, { dangerouslySkipEmit: true }); // A client response contains its associated sessions, along with a fresh token, so we dispatch a token update event. if (!this.session?.lastActiveToken && !isValidBrowserOnline()) { @@ -2835,21 +2837,37 @@ export class Clerk implements ClerkInterface { this.#emit(); }; - #getLastActiveOrganizationFromSession = () => { - const orgMemberships = this.session?.user.organizationMemberships || []; - return ( - orgMemberships.map(om => om.organization).find(org => org.id === this.session?.lastActiveOrganizationId) || null - ); + #getLastActiveOrganizationFromSession = (session = this.session) => { + const orgMemberships = session?.user.organizationMemberships || []; + return orgMemberships.map(om => om.organization).find(org => org.id === session?.lastActiveOrganizationId) || null; + }; + + #getAccessorsFromSession = (session = this.session) => { + return { + session: session || null, + organization: this.#getLastActiveOrganizationFromSession(session), + user: session ? session.user : null, + }; }; - #setAccessors = (session?: SignedInSessionResource | null) => { - this.session = session || null; - this.organization = this.#getLastActiveOrganizationFromSession(); - this.user = this.session ? this.session.user : null; + /** + * Updates the accessors for the Clerk singleton and emits. + * If dangerouslySkipEmit is true, the emit will be skipped and you are responsible for calling #emit() yourself. This is dangerous because if there is a gap between setting these and emitting, library consumers that both read state directly and set up listeners could end up in a inconsistent state. + */ + #updateAccessors = (session?: SignedInSessionResource | null, options?: { dangerouslySkipEmit?: boolean }) => { + const { session: newSession, organization, user } = this.#getAccessorsFromSession(session); + + this.session = newSession; + this.organization = organization; + this.user = user; + + if (!options?.dangerouslySkipEmit) { + this.#emit(); + } }; - #getSessionFromClient = (sessionId: string | undefined): SignedInSessionResource | null => { - return this.client?.signedInSessions.find(x => x.id === sessionId) || null; + #getSessionFromClient = (sessionId: string | undefined, client = this.client): SignedInSessionResource | null => { + return client?.signedInSessions.find(x => x.id === sessionId) || null; }; #handleImpersonationFab = () => { From 05debd5e9765534942a7d45ecb851012396ff39e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 27 Nov 2025 10:01:50 +0100 Subject: [PATCH 25/35] Fix getSnapshot stale closure issue --- .../src/react/hooks/base/useClientBase.ts | 16 +++++++------- .../react/hooks/base/useOrganizationBase.ts | 14 ++++++------- .../src/react/hooks/base/useSessionBase.ts | 14 ++++++------- .../src/react/hooks/base/useUserBase.ts | 21 +++++++++++-------- 4 files changed, 31 insertions(+), 34 deletions(-) diff --git a/packages/shared/src/react/hooks/base/useClientBase.ts b/packages/shared/src/react/hooks/base/useClientBase.ts index 5a2b3427a71..a0a32bb0d69 100644 --- a/packages/shared/src/react/hooks/base/useClientBase.ts +++ b/packages/shared/src/react/hooks/base/useClientBase.ts @@ -1,4 +1,4 @@ -import { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react'; +import { useCallback, useDeferredValue, useSyncExternalStore } from 'react'; import type { ClientResource } from '@/types'; @@ -8,16 +8,14 @@ const initialSnapshot = undefined; export function useClientBase(): ClientResource | null | undefined { const clerk = useClerkInstanceContext(); - const snapshot = useMemo(() => { - if (!clerk.loaded) { - return initialSnapshot; - } - return clerk.client; - }, [clerk.client, clerk.loaded]); - const client = useSyncExternalStore( useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), - useCallback(() => snapshot, [snapshot]), + useCallback(() => { + if (!clerk.loaded) { + return initialSnapshot; + } + return clerk.client; + }, [clerk.client, initialSnapshot, clerk.loaded]), useCallback(() => initialSnapshot, []), ); diff --git a/packages/shared/src/react/hooks/base/useOrganizationBase.ts b/packages/shared/src/react/hooks/base/useOrganizationBase.ts index 04a464b7149..09eeb7be993 100644 --- a/packages/shared/src/react/hooks/base/useOrganizationBase.ts +++ b/packages/shared/src/react/hooks/base/useOrganizationBase.ts @@ -15,16 +15,14 @@ export function useOrganizationBase(): OrganizationResource | null | undefined { return initialStateContext.organization; }, [initialStateContext]); - const snapshot = useMemo(() => { - if (!clerk.loaded) { - return initialSnapshot; - } - return clerk.organization; - }, [clerk.organization, initialSnapshot, clerk.loaded]); - const organization = useSyncExternalStore( useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), - useCallback(() => snapshot, [snapshot]), + useCallback(() => { + if (!clerk.loaded) { + return initialSnapshot; + } + return clerk.organization; + }, [clerk.organization, initialSnapshot, clerk.loaded]), useCallback(() => initialSnapshot, [initialSnapshot]), ); diff --git a/packages/shared/src/react/hooks/base/useSessionBase.ts b/packages/shared/src/react/hooks/base/useSessionBase.ts index 7232573e65a..cdcb8fd08e2 100644 --- a/packages/shared/src/react/hooks/base/useSessionBase.ts +++ b/packages/shared/src/react/hooks/base/useSessionBase.ts @@ -15,16 +15,14 @@ export function useSessionBase(): SignedInSessionResource | null | undefined { return initialStateContext.session as SignedInSessionResource; }, [initialStateContext]); - const snapshot = useMemo(() => { - if (!clerk.loaded) { - return initialSnapshot; - } - return clerk.session; - }, [clerk.session, initialSnapshot, clerk.loaded]); - const session = useSyncExternalStore( useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), - useCallback(() => snapshot, [snapshot]), + useCallback(() => { + if (!clerk.loaded) { + return initialSnapshot; + } + return clerk.session; + }, [clerk.session, initialSnapshot, clerk.loaded]), useCallback(() => initialSnapshot, [initialSnapshot]), ); diff --git a/packages/shared/src/react/hooks/base/useUserBase.ts b/packages/shared/src/react/hooks/base/useUserBase.ts index 765336257cf..4c4aa37f220 100644 --- a/packages/shared/src/react/hooks/base/useUserBase.ts +++ b/packages/shared/src/react/hooks/base/useUserBase.ts @@ -15,16 +15,19 @@ export function useUserBase(): UserResource | null | undefined { return initialStateContext.user; }, [initialStateContext]); - const snapshot = useMemo(() => { - if (!clerk.loaded) { - return initialSnapshot; - } - return clerk.user; - }, [clerk.user, initialSnapshot, clerk.loaded]); - const user = useSyncExternalStore( - useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), - useCallback(() => snapshot, [snapshot]), + useCallback( + callback => { + return clerk.addListener(callback, { skipInitialEmit: true }); + }, + [clerk], + ), + useCallback(() => { + if (!clerk.loaded) { + return initialSnapshot; + } + return clerk.user; + }, [clerk.user, initialSnapshot, clerk.loaded]), useCallback(() => initialSnapshot, [initialSnapshot]), ); From 8f23b8bec7f05f4e1f12e48decf18d7f6eff08ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 1 Dec 2025 12:14:52 +0100 Subject: [PATCH 26/35] Update tests --- .../src/hooks/__tests__/useAuth.test.tsx | 13 ++--- packages/react/src/hooks/useAuth.ts | 2 +- .../react/__tests__/payment-element.test.tsx | 49 ++++++------------- 3 files changed, 22 insertions(+), 42 deletions(-) diff --git a/packages/react/src/hooks/__tests__/useAuth.test.tsx b/packages/react/src/hooks/__tests__/useAuth.test.tsx index 2e1d9af4a10..6a5ecbbb802 100644 --- a/packages/react/src/hooks/__tests__/useAuth.test.tsx +++ b/packages/react/src/hooks/__tests__/useAuth.test.tsx @@ -1,11 +1,10 @@ import { createCheckAuthorization } from '@clerk/shared/authorization'; -import { ClerkInstanceContext } from '@clerk/shared/react'; +import { ClerkInstanceContext, InitialStateProvider } from '@clerk/shared/react'; import type { LoadedClerk, UseAuthReturn } from '@clerk/shared/types'; import { render, renderHook } from '@testing-library/react'; import React from 'react'; import { afterAll, beforeAll, beforeEach, describe, expect, expectTypeOf, it, test, vi } from 'vitest'; -import { AuthContext, InitialAuthContext } from '../../contexts/AuthContext'; import { errorThrower } from '../../errors/errorThrower'; import { invalidStateError } from '../../errors/messages'; import { useAuth, useDerivedAuth } from '../useAuth'; @@ -69,12 +68,10 @@ describe('useAuth', () => { test('renders the correct values when wrapped in ', () => { expect(() => { render( - - - - - - + + + + , ); }).not.toThrow(); diff --git a/packages/react/src/hooks/useAuth.ts b/packages/react/src/hooks/useAuth.ts index be1096d1330..d392f6b4a2a 100644 --- a/packages/react/src/hooks/useAuth.ts +++ b/packages/react/src/hooks/useAuth.ts @@ -9,8 +9,8 @@ import type { UseAuthReturn, } from '@clerk/shared/types'; import { useCallback } from 'react'; -import { useAuthState } from 'src/contexts/AuthContext'; +import { useAuthState } from '../contexts/AuthContext'; import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext'; import { errorThrower } from '../errors/errorThrower'; import { invalidStateError } from '../errors/messages'; diff --git a/packages/shared/src/react/__tests__/payment-element.test.tsx b/packages/shared/src/react/__tests__/payment-element.test.tsx index e256836e4ae..a201d775b90 100644 --- a/packages/shared/src/react/__tests__/payment-element.test.tsx +++ b/packages/shared/src/react/__tests__/payment-element.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { __experimental_PaymentElement, __experimental_PaymentElementProvider } from '../billing/payment-element'; -import { ClerkInstanceContext, OptionsContext, OrganizationProvider, UserContext } from '../contexts'; +import { ClerkInstanceContext, OptionsContext } from '../contexts'; // Mock the Stripe components vi.mock('../stripe-react', () => ({ @@ -186,11 +186,6 @@ describe('PaymentElement Localization', () => { }, }; - const mockUser = { - id: 'user_123', - initializePaymentMethod: mockInitializePaymentMethod, - }; - const renderWithLocale = (locale: string) => { // Mock the __internal_getOption to return the expected localization mockGetOption.mockImplementation(key => { @@ -206,15 +201,11 @@ describe('PaymentElement Localization', () => { return render( - - - - <__experimental_PaymentElementProvider checkout={mockCheckout}> - <__experimental_PaymentElement fallback={
Loading...
} /> - -
-
-
+ + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
, ); }; @@ -239,15 +230,11 @@ describe('PaymentElement Localization', () => { render( - - - - <__experimental_PaymentElementProvider checkout={mockCheckout}> - <__experimental_PaymentElement fallback={
Loading...
} /> - -
-
-
+ + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
, ); @@ -281,15 +268,11 @@ describe('PaymentElement Localization', () => { const { unmount } = render( - - - - <__experimental_PaymentElementProvider checkout={mockCheckout}> - <__experimental_PaymentElement fallback={
Loading...
} /> - -
-
-
+ + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
, ); From dfc6e646bb8af787e3721ef2100f81f6482ec1d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 1 Dec 2025 17:05:44 +0100 Subject: [PATCH 27/35] Remove useDeferredValue --- packages/react/src/contexts/AuthContext.tsx | 8 ++------ packages/shared/src/react/hooks/base/useClientBase.ts | 8 ++------ .../shared/src/react/hooks/base/useOrganizationBase.ts | 8 ++------ packages/shared/src/react/hooks/base/useSessionBase.ts | 8 ++------ packages/shared/src/react/hooks/base/useUserBase.ts | 8 ++------ 5 files changed, 10 insertions(+), 30 deletions(-) diff --git a/packages/react/src/contexts/AuthContext.tsx b/packages/react/src/contexts/AuthContext.tsx index ac26fb7db9d..fe66b5805f7 100644 --- a/packages/react/src/contexts/AuthContext.tsx +++ b/packages/react/src/contexts/AuthContext.tsx @@ -9,7 +9,7 @@ import type { OrganizationCustomRoleKey, SessionStatusClaim, } from '@clerk/shared/types'; -import { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react'; +import { useCallback, useMemo, useSyncExternalStore } from 'react'; type AuthStateValue = { userId: string | null | undefined; @@ -69,11 +69,7 @@ export function useAuthState(): AuthStateValue { useCallback(() => initialSnapshot, [initialSnapshot]), ); - // If an updates comes in during a transition, uSES usually deopts that transition to be synchronous, - // which for example means that already mounted boundaries might suddenly show their fallback. - // This makes all auth state changes into transitions, but does not deopt to be synchronous. If it's - // called during a transition, it immediately uses the new value without deferring. - return useDeferredValue(authState); + return authState; } function authStateFromFull(derivedState: DeriveStateReturnType) { diff --git a/packages/shared/src/react/hooks/base/useClientBase.ts b/packages/shared/src/react/hooks/base/useClientBase.ts index a0a32bb0d69..06f33760643 100644 --- a/packages/shared/src/react/hooks/base/useClientBase.ts +++ b/packages/shared/src/react/hooks/base/useClientBase.ts @@ -1,4 +1,4 @@ -import { useCallback, useDeferredValue, useSyncExternalStore } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; import type { ClientResource } from '@/types'; @@ -19,9 +19,5 @@ export function useClientBase(): ClientResource | null | undefined { useCallback(() => initialSnapshot, []), ); - // If an updates comes in during a transition, uSES usually deopts that transition to be synchronous, - // which for example means that already mounted boundaries might suddenly show their fallback. - // This makes all auth state changes into transitions, but does not deopt to be synchronous. If it's - // called during a transition, it immediately uses the new value without deferring. - return useDeferredValue(client); + return client; } diff --git a/packages/shared/src/react/hooks/base/useOrganizationBase.ts b/packages/shared/src/react/hooks/base/useOrganizationBase.ts index 09eeb7be993..c45a66cce39 100644 --- a/packages/shared/src/react/hooks/base/useOrganizationBase.ts +++ b/packages/shared/src/react/hooks/base/useOrganizationBase.ts @@ -1,4 +1,4 @@ -import { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react'; +import { useCallback, useMemo, useSyncExternalStore } from 'react'; import type { OrganizationResource } from '@/types'; @@ -26,9 +26,5 @@ export function useOrganizationBase(): OrganizationResource | null | undefined { useCallback(() => initialSnapshot, [initialSnapshot]), ); - // If an updates comes in during a transition, uSES usually deopts that transition to be synchronous, - // which for example means that already mounted boundaries might suddenly show their fallback. - // This makes all auth state changes into transitions, but does not deopt to be synchronous. If it's - // called during a transition, it immediately uses the new value without deferring. - return useDeferredValue(organization); + return organization; } diff --git a/packages/shared/src/react/hooks/base/useSessionBase.ts b/packages/shared/src/react/hooks/base/useSessionBase.ts index cdcb8fd08e2..9214f297efc 100644 --- a/packages/shared/src/react/hooks/base/useSessionBase.ts +++ b/packages/shared/src/react/hooks/base/useSessionBase.ts @@ -1,4 +1,4 @@ -import { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react'; +import { useCallback, useMemo, useSyncExternalStore } from 'react'; import type { SignedInSessionResource } from '@/types'; @@ -26,9 +26,5 @@ export function useSessionBase(): SignedInSessionResource | null | undefined { useCallback(() => initialSnapshot, [initialSnapshot]), ); - // If an updates comes in during a transition, uSES usually deopts that transition to be synchronous, - // which for example means that already mounted boundaries might suddenly show their fallback. - // This makes all auth state changes into transitions, but does not deopt to be synchronous. If it's - // called during a transition, it immediately uses the new value without deferring. - return useDeferredValue(session); + return session; } diff --git a/packages/shared/src/react/hooks/base/useUserBase.ts b/packages/shared/src/react/hooks/base/useUserBase.ts index 4c4aa37f220..6874127e629 100644 --- a/packages/shared/src/react/hooks/base/useUserBase.ts +++ b/packages/shared/src/react/hooks/base/useUserBase.ts @@ -1,4 +1,4 @@ -import { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react'; +import { useCallback, useMemo, useSyncExternalStore } from 'react'; import type { UserResource } from '@/types'; @@ -31,9 +31,5 @@ export function useUserBase(): UserResource | null | undefined { useCallback(() => initialSnapshot, [initialSnapshot]), ); - // If an updates comes in during a transition, uSES usually deopts that transition to be synchronous, - // which for example means that already mounted boundaries might suddenly show their fallback. - // This makes all auth state changes into transitions, but does not deopt to be synchronous. If it's - // called during a transition, it immediately uses the new value without deferring. - return useDeferredValue(user); + return user; } From f0b43d343e060b182071860dc96afcd462bc64da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 4 Dec 2025 17:05:09 +0100 Subject: [PATCH 28/35] Introduce __internal_lastEmittedResources in clerk-js to enable proper getSnapshot implementation --- packages/clerk-js/src/core/clerk.ts | 15 +++--- packages/react/src/contexts/AuthContext.tsx | 60 +++++++++++---------- packages/react/src/isomorphicClerk.ts | 5 ++ packages/shared/src/types/clerk.ts | 7 +++ 4 files changed, 54 insertions(+), 33 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 2f19b08dc1c..10c528ab4d9 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2905,15 +2905,18 @@ export class Clerk implements ClerkInterface { }); }; + public __internal_lastEmittedResources: Resources | undefined; #emit = (): void => { if (this.client) { + const resources = { + client: this.client, + session: this.session, + user: this.user, + organization: this.organization, + }; + this.__internal_lastEmittedResources = resources; for (const listener of this.#listeners) { - listener({ - client: this.client, - session: this.session, - user: this.user, - organization: this.organization, - }); + listener(resources); } } }; diff --git a/packages/react/src/contexts/AuthContext.tsx b/packages/react/src/contexts/AuthContext.tsx index fe66b5805f7..538905fcd4c 100644 --- a/packages/react/src/contexts/AuthContext.tsx +++ b/packages/react/src/contexts/AuthContext.tsx @@ -3,13 +3,14 @@ import { deriveFromClientSideState, deriveFromSsrInitialState } from '@clerk/sha import { useClerkInstanceContext, useInitialStateContext } from '@clerk/shared/react'; import type { ActClaim, - ClientResource, + InitialState, JwtPayload, OrganizationCustomPermissionKey, OrganizationCustomRoleKey, + Resources, SessionStatusClaim, } from '@clerk/shared/types'; -import { useCallback, useMemo, useSyncExternalStore } from 'react'; +import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'; type AuthStateValue = { userId: string | null | undefined; @@ -39,36 +40,37 @@ export const defaultDerivedInitialState = { export function useAuthState(): AuthStateValue { const clerk = useClerkInstanceContext(); - const initialStateContext = useInitialStateContext(); - // If we make initialState support a promise in the future, this is where we would use() that promise - const initialSnapshot = useMemo(() => { - if (!initialStateContext) { - return defaultDerivedInitialState; - } - const fullState = deriveFromSsrInitialState(initialStateContext); - return authStateFromFull(fullState); - }, [initialStateContext]); + const initialStateFromContextRaw = useInitialStateContext(); - const snapshot = useMemo(() => { - if (!clerk.loaded) { - return initialSnapshot; - } - const state = { - client: clerk.client as ClientResource, - session: clerk.session, - user: clerk.user, - organization: clerk.organization, - }; - const fullState = deriveFromClientSideState(state); - return authStateFromFull(fullState); - }, [clerk.client, clerk.session, clerk.user, clerk.organization, initialSnapshot, clerk.loaded]); + // This is never allowed to change, so we snapshot it to guarantee that + // eslint-disable-next-line react/hook-use-state + const [initialStateFromContext] = useState(initialStateFromContextRaw); - const authState = useSyncExternalStore( + const state = useSyncExternalStore( useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), - useCallback(() => snapshot, [snapshot]), - useCallback(() => initialSnapshot, [initialSnapshot]), + useCallback(() => { + if (!clerk.loaded || !clerk.__internal_lastEmittedResources) { + return initialStateFromContext; + } + + return clerk.__internal_lastEmittedResources; + // We do not want to include __internal_lastEmittedResources in the dependency array as that is not reactive, + // in the future we should useEffectEvent for this, but it's only available in React 19. + // clerk only changes identity when it's status changes, so reads to __internal_lastEmittedResources will + // always return the latest value, which is what we want. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clerk.loaded, initialStateFromContext]), + useCallback(() => initialStateFromContext, [initialStateFromContext]), ); + const authState = useMemo(() => { + if (!state) { + return defaultDerivedInitialState; + } + const fullState = isInitialState(state) ? deriveFromSsrInitialState(state) : deriveFromClientSideState(state); + return authStateFromFull(fullState); + }, [state]); + return authState; } @@ -86,3 +88,7 @@ function authStateFromFull(derivedState: DeriveStateReturnType) { factorVerificationAge: derivedState.factorVerificationAge, }; } + +function isInitialState(state: Resources | InitialState): state is InitialState { + return !('client' in state); +} diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 1ceccfad847..b4121e699e2 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -40,6 +40,7 @@ import type { OrganizationSwitcherProps, PricingTableProps, RedirectOptions, + Resources, SetActiveParams, SignInProps, SignInRedirectOptions, @@ -801,6 +802,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + get __internal_lastEmittedResources(): Resources | undefined { + return this.clerkjs?.__internal_lastEmittedResources; + } + /** * `setActive` can be used to set the active session and/or organization. */ diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 6e562d53d54..65e7557f478 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -246,6 +246,13 @@ export interface Clerk { /** Current User. */ user: UserResource | null | undefined; + /** + * Last emitted resources, maintains a stable reference to the resources between emits. + * + * @internal + */ + __internal_lastEmittedResources: Resources | undefined; + /** * Entrypoint for Clerk's Signal API containing resource signals along with accessible versions of `computed()` and * `effect()` that can be used to subscribe to changes from Signals. From 84fdb9b564dbe40fd4bdf7d26f1566f55112cd78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 9 Dec 2025 12:53:58 +0100 Subject: [PATCH 29/35] Refactor initialState --- packages/react/src/contexts/AuthContext.tsx | 19 +++++--------- packages/shared/src/react/contexts.tsx | 26 +++++++++---------- .../react/hooks/base/useOrganizationBase.ts | 19 +++++--------- .../src/react/hooks/base/useSessionBase.ts | 18 +++++-------- .../src/react/hooks/base/useUserBase.ts | 18 +++++-------- 5 files changed, 38 insertions(+), 62 deletions(-) diff --git a/packages/react/src/contexts/AuthContext.tsx b/packages/react/src/contexts/AuthContext.tsx index 538905fcd4c..dff09d210aa 100644 --- a/packages/react/src/contexts/AuthContext.tsx +++ b/packages/react/src/contexts/AuthContext.tsx @@ -10,7 +10,7 @@ import type { Resources, SessionStatusClaim, } from '@clerk/shared/types'; -import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'; +import { useCallback, useMemo, useSyncExternalStore } from 'react'; type AuthStateValue = { userId: string | null | undefined; @@ -40,27 +40,20 @@ export const defaultDerivedInitialState = { export function useAuthState(): AuthStateValue { const clerk = useClerkInstanceContext(); - const initialStateFromContextRaw = useInitialStateContext(); + const initialState = useInitialStateContext(); - // This is never allowed to change, so we snapshot it to guarantee that - // eslint-disable-next-line react/hook-use-state - const [initialStateFromContext] = useState(initialStateFromContextRaw); + const getInitialState = useCallback(() => initialState, [initialState]); const state = useSyncExternalStore( useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), useCallback(() => { if (!clerk.loaded || !clerk.__internal_lastEmittedResources) { - return initialStateFromContext; + return getInitialState(); } return clerk.__internal_lastEmittedResources; - // We do not want to include __internal_lastEmittedResources in the dependency array as that is not reactive, - // in the future we should useEffectEvent for this, but it's only available in React 19. - // clerk only changes identity when it's status changes, so reads to __internal_lastEmittedResources will - // always return the latest value, which is what we want. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [clerk.loaded, initialStateFromContext]), - useCallback(() => initialStateFromContext, [initialStateFromContext]), + }, [clerk, getInitialState]), + getInitialState, ); const authState = useMemo(() => { diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index a32345355dc..3c50d12a6f7 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -1,7 +1,7 @@ 'use client'; import type { PropsWithChildren } from 'react'; -import React from 'react'; +import React, { useState } from 'react'; import type { BillingSubscriptionPlanPeriod, @@ -22,7 +22,7 @@ export { useSessionBase as useSessionContext } from './hooks/base/useSessionBase const [InitialStateContext, _useInitialStateContext] = createContextAndHook< InitialState | Promise | undefined >('InitialStateContext'); -export { useInitialStateContext }; + export function InitialStateProvider({ children, initialState, @@ -30,22 +30,22 @@ export function InitialStateProvider({ children: React.ReactNode; initialState: InitialState | Promise | undefined; }) { - const initialStateCtx = React.useMemo(() => ({ value: initialState }), [initialState]); + // The initialState is not allowed to change, we snapshot it to turn that expectation into a guarantee. + // Note that despite this, it could still be different for different parts of the React tree which is fine, + // but that requires using a separate provider. + // eslint-disable-next-line react/hook-use-state + const [initialStateSnapshot] = useState(initialState); + const initialStateCtx = React.useMemo(() => ({ value: initialStateSnapshot }), [initialStateSnapshot]); return {children}; } -function useInitialStateContext(): InitialState | undefined { + +export function useInitialStateContext(): InitialState | undefined { const initialState = _useInitialStateContext(); - if (!initialState) { - return undefined; - } + // @ts-expect-error TODO: If we do want to support promises, we need to throw here instead + const resolvedInitialState = initialState && 'then' in initialState ? React.use(initialState) : initialState; - if (initialState && 'then' in initialState) { - // TODO: If we want to preserve backwards compatibility, we'd need to throw here instead - // @ts-expect-error See above - return React.use(initialState); - } - return initialState; + return resolvedInitialState; } const OptionsContext = React.createContext({}); diff --git a/packages/shared/src/react/hooks/base/useOrganizationBase.ts b/packages/shared/src/react/hooks/base/useOrganizationBase.ts index c45a66cce39..4d065766867 100644 --- a/packages/shared/src/react/hooks/base/useOrganizationBase.ts +++ b/packages/shared/src/react/hooks/base/useOrganizationBase.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useSyncExternalStore } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; import type { OrganizationResource } from '@/types'; @@ -6,24 +6,19 @@ import { useClerkInstanceContext, useInitialStateContext } from '../../contexts' export function useOrganizationBase(): OrganizationResource | null | undefined { const clerk = useClerkInstanceContext(); - const initialStateContext = useInitialStateContext(); - // If we make initialState support a promise in the future, this is where we would use() that promise - const initialSnapshot = useMemo(() => { - if (!initialStateContext) { - return undefined; - } - return initialStateContext.organization; - }, [initialStateContext]); + const initialState = useInitialStateContext(); + + const getInitialState = useCallback(() => initialState?.organization, [initialState?.organization]); const organization = useSyncExternalStore( useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), useCallback(() => { if (!clerk.loaded) { - return initialSnapshot; + return getInitialState(); } return clerk.organization; - }, [clerk.organization, initialSnapshot, clerk.loaded]), - useCallback(() => initialSnapshot, [initialSnapshot]), + }, [clerk, getInitialState]), + getInitialState, ); return organization; diff --git a/packages/shared/src/react/hooks/base/useSessionBase.ts b/packages/shared/src/react/hooks/base/useSessionBase.ts index 9214f297efc..9c5306b4200 100644 --- a/packages/shared/src/react/hooks/base/useSessionBase.ts +++ b/packages/shared/src/react/hooks/base/useSessionBase.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useSyncExternalStore } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; import type { SignedInSessionResource } from '@/types'; @@ -6,24 +6,18 @@ import { useClerkInstanceContext, useInitialStateContext } from '../../contexts' export function useSessionBase(): SignedInSessionResource | null | undefined { const clerk = useClerkInstanceContext(); - const initialStateContext = useInitialStateContext(); - // If we make initialState support a promise in the future, this is where we would use() that promise - const initialSnapshot = useMemo(() => { - if (!initialStateContext) { - return undefined; - } - return initialStateContext.session as SignedInSessionResource; - }, [initialStateContext]); + const initialState = useInitialStateContext(); + const getInitialState = useCallback(() => initialState?.session as SignedInSessionResource, [initialState?.session]); const session = useSyncExternalStore( useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), useCallback(() => { if (!clerk.loaded) { - return initialSnapshot; + return getInitialState(); } return clerk.session; - }, [clerk.session, initialSnapshot, clerk.loaded]), - useCallback(() => initialSnapshot, [initialSnapshot]), + }, [clerk, getInitialState]), + getInitialState, ); return session; diff --git a/packages/shared/src/react/hooks/base/useUserBase.ts b/packages/shared/src/react/hooks/base/useUserBase.ts index 6874127e629..dfe393f91bc 100644 --- a/packages/shared/src/react/hooks/base/useUserBase.ts +++ b/packages/shared/src/react/hooks/base/useUserBase.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useSyncExternalStore } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; import type { UserResource } from '@/types'; @@ -6,14 +6,8 @@ import { useClerkInstanceContext, useInitialStateContext } from '../../contexts' export function useUserBase(): UserResource | null | undefined { const clerk = useClerkInstanceContext(); - const initialStateContext = useInitialStateContext(); - // If we make initialState support a promise in the future, this is where we would use() that promise - const initialSnapshot = useMemo(() => { - if (!initialStateContext) { - return undefined; - } - return initialStateContext.user; - }, [initialStateContext]); + const initialState = useInitialStateContext(); + const getInitialState = useCallback(() => initialState?.user, [initialState?.user]); const user = useSyncExternalStore( useCallback( @@ -24,11 +18,11 @@ export function useUserBase(): UserResource | null | undefined { ), useCallback(() => { if (!clerk.loaded) { - return initialSnapshot; + return getInitialState(); } return clerk.user; - }, [clerk.user, initialSnapshot, clerk.loaded]), - useCallback(() => initialSnapshot, [initialSnapshot]), + }, [clerk, getInitialState]), + getInitialState, ); return user; From 9d6f7f95a90e7216c83ea17c00cd3d6ec9f0e4d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 9 Dec 2025 13:19:20 +0100 Subject: [PATCH 30/35] Revert supporting initialState as a promise (for now) --- .../src/app-router/server/ClerkProvider.tsx | 2 +- packages/react/src/types.ts | 2 +- .../shared/src/react/ClerkContextProvider.tsx | 2 +- packages/shared/src/react/contexts.tsx | 15 ++++++++------- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index 9117d0b3a1e..f3c015f57f7 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -36,7 +36,7 @@ export async function ClerkProvider( const propsWithEnvs = mergeNextClerkPropsWithEnv({ ...rest, - initialState: statePromiseOrValue as InitialState | Promise | undefined, + initialState: (await statePromiseOrValue) as InitialState | undefined, nonce: await noncePromiseOrValue, }); diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index be8783432aa..262d0aa9481 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -41,7 +41,7 @@ export type ClerkProviderProps = Omit; + initialState?: InitialState; /** * Indicates to silently fail the initialization process when the publishable keys is not provided, instead of throwing an error. * @default false diff --git a/packages/shared/src/react/ClerkContextProvider.tsx b/packages/shared/src/react/ClerkContextProvider.tsx index a4dc0871d2d..05f325c6acc 100644 --- a/packages/shared/src/react/ClerkContextProvider.tsx +++ b/packages/shared/src/react/ClerkContextProvider.tsx @@ -14,7 +14,7 @@ type ClerkContextProps = { clerkStatus?: ClerkStatus; children: React.ReactNode; swrConfig?: any; - initialState?: InitialState | Promise; + initialState?: InitialState; }; export function ClerkContextProvider(props: ClerkContextProps): JSX.Element | null { diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index b7c2e588653..f848aca9026 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -19,16 +19,16 @@ export { useUserBase as useUserContext } from './hooks/base/useUserBase'; export { useClientBase as useClientContext } from './hooks/base/useClientBase'; export { useSessionBase as useSessionContext } from './hooks/base/useSessionBase'; -const [InitialStateContext, _useInitialStateContext] = createContextAndHook< - InitialState | Promise | undefined ->('InitialStateContext'); +const [InitialStateContext, _useInitialStateContext] = createContextAndHook( + 'InitialStateContext', +); export function InitialStateProvider({ children, initialState, }: { children: React.ReactNode; - initialState: InitialState | Promise | undefined; + initialState: InitialState | undefined; }) { // The initialState is not allowed to change, we snapshot it to turn that expectation into a guarantee. // Note that despite this, it could still be different for different parts of the React tree which is fine, @@ -42,10 +42,11 @@ export function InitialStateProvider({ export function useInitialStateContext(): InitialState | undefined { const initialState = _useInitialStateContext(); - // @ts-expect-error TODO: If we do want to support promises, we need to throw here instead - const resolvedInitialState = initialState && 'then' in initialState ? React.use(initialState) : initialState; + // If we want to support passing initialState as a promise, this is where we would handle that. + // Note that we can not use(promise) as long as we support React 18, so we'll need to have some extra handling + // and throw the promise instead. - return resolvedInitialState; + return initialState; } const OptionsContext = React.createContext({}); From e3b3d7ee4f0a9e171f669a995fac3019dadb4213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 9 Dec 2025 13:19:31 +0100 Subject: [PATCH 31/35] Fix mergeNextClerkPropsWithEnv types --- .../nextjs/src/app-router/server/keyless-provider.tsx | 4 ++-- packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/nextjs/src/app-router/server/keyless-provider.tsx b/packages/nextjs/src/app-router/server/keyless-provider.tsx index e81a2f9d6fa..ebf42687166 100644 --- a/packages/nextjs/src/app-router/server/keyless-provider.tsx +++ b/packages/nextjs/src/app-router/server/keyless-provider.tsx @@ -13,7 +13,7 @@ import { ClientClerkProvider } from '../client/ClerkProvider'; import { deleteKeylessAction } from '../keyless-actions'; export async function getKeylessStatus( - params: Without, + params: Without, ) { let [shouldRunAsKeyless, runningWithClaimedKeys, locallyStoredPublishableKey] = [false, false, '']; if (canUseKeyless) { @@ -32,7 +32,7 @@ export async function getKeylessStatus( } type KeylessProviderProps = PropsWithChildren<{ - rest: Without; + rest: Without; runningWithClaimedKeys: boolean; }>; diff --git a/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts b/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts index 5e758db5cdb..c58b77f7fc7 100644 --- a/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts +++ b/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts @@ -1,10 +1,12 @@ +import type { Ui } from '@clerk/react/internal'; import { isTruthy } from '@clerk/shared/underscore'; import { SDK_METADATA } from '../server/constants'; import type { NextClerkProviderProps } from '../types'; -// @ts-ignore - https://github.com/microsoft/TypeScript/issues/47663 -export const mergeNextClerkPropsWithEnv = (props: Omit): any => { +export const mergeNextClerkPropsWithEnv = ( + props: Omit, 'children'>, +) => { return { ...props, publishableKey: props.publishableKey || process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY || '', @@ -31,5 +33,5 @@ export const mergeNextClerkPropsWithEnv = (props: Omit, 'children'>; }; From 1fed36639223ef394f4ec87b58124f49940951f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 9 Dec 2025 17:29:54 +0100 Subject: [PATCH 32/35] Trigger --- packages/react/src/contexts/AuthContext.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/contexts/AuthContext.tsx b/packages/react/src/contexts/AuthContext.tsx index dff09d210aa..08322b2d4d2 100644 --- a/packages/react/src/contexts/AuthContext.tsx +++ b/packages/react/src/contexts/AuthContext.tsx @@ -41,7 +41,6 @@ export const defaultDerivedInitialState = { export function useAuthState(): AuthStateValue { const clerk = useClerkInstanceContext(); const initialState = useInitialStateContext(); - const getInitialState = useCallback(() => initialState, [initialState]); const state = useSyncExternalStore( From 7486150dfe706a0dcf74f7a4a00f47db9c48e3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 10 Dec 2025 17:17:07 +0100 Subject: [PATCH 33/35] Add basic transition tests --- .../src/app/transitions/page.tsx | 171 ++++++++++++++++ integration/testUtils/usersService.ts | 2 +- integration/tests/transitions.test.ts | 189 ++++++++++++++++++ 3 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 integration/templates/next-app-router/src/app/transitions/page.tsx create mode 100644 integration/tests/transitions.test.ts diff --git a/integration/templates/next-app-router/src/app/transitions/page.tsx b/integration/templates/next-app-router/src/app/transitions/page.tsx new file mode 100644 index 00000000000..af4a4348f6b --- /dev/null +++ b/integration/templates/next-app-router/src/app/transitions/page.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { OrganizationSwitcher, useAuth, useOrganizationList } from '@clerk/nextjs'; +import { OrganizationMembershipResource, SetActive } from '@clerk/shared/types'; +import { Suspense, useState, useTransition } from 'react'; + +// Quick and dirty promise cache to enable Suspense "fetching" +const cachedPromises = new Map>(); +const getCachedPromise = (key: string, value: string | undefined | null, delay: number = 1000) => { + if (cachedPromises.has(`${key}-${value}-${delay}`)) { + return cachedPromises.get(`${key}-${value}-${delay}`)!; + } + const promise = new Promise(resolve => { + setTimeout(() => { + const returnValue = `Fetched value: ${value}`; + (promise as any).status = 'fulfilled'; + (promise as any).value = returnValue; + resolve(returnValue); + }, delay); + }); + cachedPromises.set(`${key}-${value}-${delay}`, promise); + return promise; +}; + +export default function TransitionsPage() { + return ( +
+
+ + +
+
+ Loading...
} /> +
+
+
+
+ + Loading...
}> + +
+ + + ); +} + +// This is a hack to be able to control the start and stop of a transition by using a promise +function TransitionController() { + const [transitionPromise, setTransitionPromise] = useState | null>(null); + const [pending, startTransition] = useTransition(); + return ( +
+ +
+ ); +} + +function TransitionSwitcher() { + const { isLoaded, userMemberships, setActive } = useOrganizationList({ userMemberships: true }); + + if (!isLoaded || !userMemberships.data) { + return null; + } + + return ( +
+ {userMemberships.data.map(membership => ( + + ))} +
+ ); +} + +function TransitionSwitcherButton({ + membership, + setActive, +}: { + membership: OrganizationMembershipResource; + setActive: SetActive; +}) { + const [pending, startTransition] = useTransition(); + return ( + + ); +} + +function AuthStatePresenter() { + const { orgId, sessionId, userId } = useAuth(); + + return ( +
+

Auth state

+
+ SessionId: {String(sessionId)} +
+
+ UserId: {String(userId)} +
+
+ OrgId: {String(orgId)} +
+
+ ); +} + +function Fetcher() { + const { orgId } = useAuth(); + + if (!orgId) { + return null; + } + + const promise = getCachedPromise('fetcher', orgId, 1000); + if (promise && (promise as any).status !== 'fulfilled') { + throw promise; + } + + return ( +
+

Fetcher

+
{(promise as any).value}
+
+ ); +} diff --git a/integration/testUtils/usersService.ts b/integration/testUtils/usersService.ts index eecab30dbf4..64bfed75dfa 100644 --- a/integration/testUtils/usersService.ts +++ b/integration/testUtils/usersService.ts @@ -207,7 +207,7 @@ export const createUserService = (clerkClient: ClerkClient) => { const name = faker.animal.dog(); const organization = await withErrorLogging('createOrganization', () => clerkClient.organizations.createOrganization({ - name: faker.animal.dog(), + name: name, createdBy: userId, }), ); diff --git a/integration/tests/transitions.test.ts b/integration/tests/transitions.test.ts new file mode 100644 index 00000000000..e69c2c7d9c0 --- /dev/null +++ b/integration/tests/transitions.test.ts @@ -0,0 +1,189 @@ +import { parsePublishableKey } from '@clerk/shared/keys'; +import { clerkSetup } from '@clerk/testing/playwright'; +import { test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeOrganization, FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +/* + These tests try to verify some existing transition behaviors. They are not comprehensive, and do not necessarily + document the desired behavior but the one we currently have, as changing some of these behaviors might be considered + a breaking change. + + Note that it is unclear if we can support transitions fully for auth state as they involve cookies, which can not fork. + + The tests use organization switching and useAuth as a stand-in for other type of auth state changes and hooks, + but the strategy and behavior should be the same across other type of state changes and hooks as well and we could + add more tests to have better coverage. + + We might need to come up with a better strategy to test these behaviors in the future, but this is a start. + + Note that these tests are entangled with the specific page implementation details and so are hard to understand + without reading the /transitions page code in the template. +*/ +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('transitions @nextjs', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + let fakeOrganization: FakeOrganization; + let fakeOrganization2: FakeOrganization; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + + const publishableKey = appConfigs.envs.withEmailCodes.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + const secretKey = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_SECRET_KEY'); + const apiUrl = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_API_URL'); + const { frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey); + + // Not needed for the normal test setup, but makes it easier to run the tests against a manually started app + await clerkSetup({ + publishableKey, + frontendApiUrl, + secretKey, + // @ts-expect-error Not typed + apiUrl, + dotenv: false, + }); + + fakeUser = u.services.users.createFakeUser(); + const user = await u.services.users.createBapiUser(fakeUser); + fakeOrganization = await u.services.users.createFakeOrganization(user.id); + fakeOrganization2 = await u.services.users.createFakeOrganization(user.id); + }); + + test.afterAll(async () => { + await fakeOrganization.delete(); + await fakeOrganization2.delete(); + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + /* + This test verifies the page behavior when transitions are not involved. State updates immediately and + already mounted Suspense boundaries are suspended so the fallback shows. + + If Clerk made auth changes as transitions, with full support, the behavior would be that the Suspense fallback + would not be shown, and orgId would not update until the full transition, including data fetching, was complete. + */ + test('should switch to the new organization immediately when not using transitions', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + + await u.po.page.goToRelative('/transitions'); + + // This page is not using ``, so orgId should be undefined during page load + await test.expect(u.po.page.getByTestId('org-id')).toHaveText('undefined'); + + await test.expect(u.po.page.getByTestId('org-id')).toHaveText(fakeOrganization2.organization.id); + // When orgId comes in, this page triggers a mock Suspense fetch + await test.expect(u.po.page.getByTestId('fetcher-fallback')).toBeVisible(); + await test + .expect(u.po.page.getByTestId('fetcher-result')) + .toHaveText(`Fetched value: ${fakeOrganization2.organization.id}`); + + // Switch to new organization + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + await u.po.organizationSwitcher.toggleTrigger(); + await test.expect(u.page.locator('.cl-organizationSwitcherPopoverCard')).toBeVisible(); + await u.page.getByText(fakeOrganization.name, { exact: true }).click(); + + // When orgId updates, we re-suspend and "fetch" the new value + await test.expect(u.po.page.getByTestId('org-id')).toHaveText(fakeOrganization.organization.id); + await test.expect(u.po.page.getByTestId('fetcher-fallback')).toBeVisible(); + await test + .expect(u.po.page.getByTestId('fetcher-result')) + .toHaveText(`Fetched value: ${fakeOrganization.organization.id}`); + }); + + /* + This test verifies that auth state changes interrupt an already started, but unrelated transition, setting + the state immediately and suspending already mounted Suspense boundaries. + + If Clerk made auth changes as transitions, with full support, the behavior would be that the Suspense fallback + would not be shown, and orgId would not update until the full transition, including data fetching, was complete. + */ + test('should switch to the new organization immediately when a transition is in progress', async ({ + page, + context, + }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + + await u.po.page.goToRelative('/transitions'); + + // This page is not using ``, so orgId should be undefined during page load + await test.expect(u.po.page.getByTestId('org-id')).toHaveText('undefined'); + + await test.expect(u.po.page.getByTestId('org-id')).toHaveText(fakeOrganization.organization.id); + // When orgId comes in, this page triggers a mock Suspense fetch + await test.expect(u.po.page.getByTestId('fetcher-fallback')).toBeVisible(); + await test + .expect(u.po.page.getByTestId('fetcher-result')) + .toHaveText(`Fetched value: ${fakeOrganization.organization.id}`); + + // Start unrelated transition + await u.po.page.getByRole('button', { name: 'Start transition' }).click(); + await test.expect(u.po.page.getByRole('button', { name: 'Finish transition' })).toBeVisible(); + + // Switch to new organization + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + await u.po.organizationSwitcher.toggleTrigger(); + await test.expect(u.page.locator('.cl-organizationSwitcherPopoverCard')).toBeVisible(); + await u.page.getByText(fakeOrganization2.name, { exact: true }).click(); + + // When orgId updates, we re-suspend and "fetch" the new value + await test.expect(u.po.page.getByTestId('org-id')).toHaveText(fakeOrganization2.organization.id); + await test.expect(u.po.page.getByTestId('fetcher-fallback')).toBeVisible(); + await test + .expect(u.po.page.getByTestId('fetcher-result')) + .toHaveText(`Fetched value: ${fakeOrganization2.organization.id}`); + + // Finish unrelated transition - Should have been pending until now + await u.po.page.getByRole('button', { name: 'Finish transition' }).click(); + await test.expect(u.po.page.getByRole('button', { name: 'Start transition' })).toBeVisible(); + }); + + /* + This test verifies the current behavior when setActive is triggered inside a transition. + + If setActive/Clerk fully supported transitions, the behavior would be that the Suspense fallback + would not be shown, and orgId would not update until the full transition, including data fetching, was complete. + */ + test('should switch to the new organization immediately when triggered inside a transition', async ({ + page, + context, + }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + + await u.po.page.goToRelative('/transitions'); + + // This page is not using ``, so orgId should be undefined during page load + await test.expect(u.po.page.getByTestId('org-id')).toHaveText('undefined'); + + await test.expect(u.po.page.getByTestId('org-id')).toHaveText(fakeOrganization2.organization.id); + // When orgId comes in, this page triggers a mock Suspense fetch + await test.expect(u.po.page.getByTestId('fetcher-fallback')).toBeVisible(); + await test + .expect(u.po.page.getByTestId('fetcher-result')) + .toHaveText(`Fetched value: ${fakeOrganization2.organization.id}`); + + // Switch to new organization + await u.po.page.getByRole('button', { name: `Switch to ${fakeOrganization.name} in transition` }).click(); + await test.expect(u.po.page.getByRole('button', { name: `Switching...` })).toBeVisible(); + + // When orgId updates, we re-suspend and "fetch" the new value + await test.expect(u.po.page.getByTestId('org-id')).toHaveText(fakeOrganization.organization.id); + await test.expect(u.po.page.getByTestId('fetcher-fallback')).toBeVisible(); + await test + .expect(u.po.page.getByTestId('fetcher-result')) + .toHaveText(`Fetched value: ${fakeOrganization.organization.id}`); + }); +}); From c371efe311383e92639ff4bde420dab6c3becfe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 10 Dec 2025 17:46:38 +0100 Subject: [PATCH 34/35] Remove failing @ts-expect-error in test --- .../templates/next-app-router/src/app/transitions/page.tsx | 4 ---- packages/shared/src/react/hooks/base/useClientBase.ts | 5 +++-- packages/shared/src/react/hooks/base/useSessionBase.ts | 4 +++- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/integration/templates/next-app-router/src/app/transitions/page.tsx b/integration/templates/next-app-router/src/app/transitions/page.tsx index af4a4348f6b..48cb59b7fee 100644 --- a/integration/templates/next-app-router/src/app/transitions/page.tsx +++ b/integration/templates/next-app-router/src/app/transitions/page.tsx @@ -72,8 +72,6 @@ function TransitionController() { (promise as any).resolve = resolve; setTransitionPromise(promise); - // Async transition functions were introduced in React 19, but test outcome is the same regardless - // @ts-expect-error startTransition(async () => { await promise; }); @@ -117,8 +115,6 @@ function TransitionSwitcherButton({ return (