Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bc169ba
Refactor promises in NextJS ClerkProvider
Ephem Nov 10, 2025
3fa0633
Remove PromisifiedAuth
Ephem Nov 10, 2025
e5d4d4b
Remove initialAuthState from useAuth
Ephem Nov 10, 2025
3b6687e
Update expo useAuth to match new signature
Ephem Nov 10, 2025
11a38da
Add changeset
Ephem Nov 10, 2025
cf02da1
Remove special isNext13 handling from Next ClerkProvider
Ephem Nov 11, 2025
9f9477d
Temporarily remove affected packages from changeset
Ephem Nov 11, 2025
c7ed168
Add back affected packages
Ephem Nov 11, 2025
c4eadb4
Merge branch 'vincent-and-the-doctor' into fredrik/remove-initial-aut…
Ephem Nov 11, 2025
2f45577
Fix changeset react package name
Ephem Nov 11, 2025
09d8a8a
Add InitialAuthStateProvider and refactor to derive authState in useA…
Ephem Nov 12, 2025
31d4f2b
Use InitialAuthStateProvider directly for nested Next ClerkProvider
Ephem Nov 12, 2025
7a5381c
Resolve !dynamic without promises
Ephem Nov 13, 2025
a23789b
Merge branch 'vincent-and-the-doctor' into fredrik/remove-initial-aut…
Ephem Nov 18, 2025
ba26f9c
Clean up AuthContext
Ephem Nov 19, 2025
d72ec7c
Merge branch 'vincent-and-the-doctor' into fredrik/remove-initial-aut…
Ephem Nov 19, 2025
19b996b
Merge branch 'vincent-and-the-doctor' into fredrik/remove-initial-aut…
Ephem Nov 19, 2025
a2adbf3
Remove AuthContext and add uSES for useAuth hook
Ephem Nov 19, 2025
c85a66c
Move ClerkContextProvider from ui to shared/react
Ephem Nov 19, 2025
f35bbbb
Update shared ClerkContextProvider to support initialState and switch…
Ephem Nov 19, 2025
288cf94
Remove SessionContext and refactor to uSES
Ephem Nov 20, 2025
5d87895
Remove UserContext and refactor to uSES
Ephem Nov 20, 2025
a8f9f60
Remove OrganizationProvider and refactor to uSES
Ephem Nov 20, 2025
f37d8d1
Remove ClientContext and refactor to uSES
Ephem Nov 20, 2025
d3d85d1
Support passing in initialState as a promise
Ephem Nov 20, 2025
5b81912
Add skipInitialEmit option to addListener and use in uSES
Ephem Nov 20, 2025
c3c79f9
Remove unrelated changeset
Ephem Nov 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import type {
InstanceType,
JoinWaitlistParams,
ListenerCallback,
ListenerOptions,
NavigateOptions,
OrganizationListProps,
OrganizationProfileProps,
Expand Down Expand Up @@ -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) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uSES reads state directly from clerk, so this was introduced to avoid extra emits every time addListener was called in a hook.

listener({
client: this.client,
session: this.session,
Expand Down
4 changes: 2 additions & 2 deletions packages/expo/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useAuthBase>[0]): UseAuthReturn => {
const { getToken: getTokenBase, ...rest } = useAuthBase(options);

const getToken: GetToken = (opts?: GetTokenOptions): Promise<string | null> =>
getTokenBase(opts)
Expand Down
17 changes: 11 additions & 6 deletions packages/nextjs/src/app-router/client/ClerkProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';
import { ClerkProvider as ReactClerkProvider } from '@clerk/react';
import { InitialStateProvider } from '@clerk/shared/react';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import React from 'react';
Expand Down Expand Up @@ -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 => {
/**
Expand Down Expand Up @@ -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 <ClerkProvider dynamic> inside a <ClerkProvider>, we do want the initial state to be available for this subtree
return <InitialStateProvider initialState={rest.initialState}>{children}</InitialStateProvider>;
}
return children;
}

if (safePublishableKey || !canUseKeyless || disableKeyless) {
return <NextClientClerkProvider {...rest}>{children}</NextClientClerkProvider>;
}
Expand Down
45 changes: 6 additions & 39 deletions packages/nextjs/src/app-router/server/ClerkProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import type { InitialState, 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';
Expand Down Expand Up @@ -32,28 +30,17 @@ export async function ClerkProvider(
) {
const { children, dynamic, ...rest } = props;

async function generateStatePromise() {
if (!dynamic) {
return Promise.resolve(null);
}
return getDynamicClerkState();
}

async function generateNonce() {
if (!dynamic) {
return Promise.resolve('');
}
return getNonceHeaders();
}
const statePromiseOrValue = dynamic ? getDynamicClerkState() : undefined;
const noncePromiseOrValue = dynamic ? getNonceHeaders() : '';

const propsWithEnvs = mergeNextClerkPropsWithEnv({
...rest,
initialState: statePromiseOrValue as InitialState | Promise<InitialState> | undefined,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not awaiting this at the top level might be considered a breaking change. I'm not sure it is technically, but it might change the loading experience of apps, so we should be careful.

We could also do this incrementally and optionally by introducing something like dynamic="stream".

nonce: await noncePromiseOrValue,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awaiting this is something we might also want to move further down.

});

const { shouldRunAsKeyless, runningWithClaimedKeys } = await getKeylessStatus(propsWithEnvs);

let output: ReactNode;

try {
const detectKeylessEnvDrift = await import('../../server/keyless-telemetry.js').then(
mod => mod.detectKeylessEnvDrift,
Expand All @@ -64,35 +51,15 @@ export async function ClerkProvider(
}

if (shouldRunAsKeyless) {
output = (
return (
<KeylessProvider
rest={propsWithEnvs}
generateNonce={generateNonce}
generateStatePromise={generateStatePromise}
runningWithClaimedKeys={runningWithClaimedKeys}
>
{children}
</KeylessProvider>
);
} else {
output = (
<ClientClerkProvider
{...propsWithEnvs}
nonce={await generateNonce()}
initialState={await generateStatePromise()}
>
{children}
</ClientClerkProvider>
);
}

if (dynamic) {
return (
// TODO: fix types so AuthObject is compatible with InitialState
<PromisifiedAuthProvider authPromise={generateStatePromise() as unknown as Promise<InitialState>}>
{output}
</PromisifiedAuthProvider>
);
}
return output;
return <ClientClerkProvider {...propsWithEnvs}>{children}</ClientClerkProvider>;
}
9 changes: 1 addition & 8 deletions packages/nextjs/src/app-router/server/keyless-provider.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -35,12 +34,10 @@ export async function getKeylessStatus(
type KeylessProviderProps = PropsWithChildren<{
rest: Without<NextClerkProviderProps, '__unstable_invokeMiddlewareOnAuthStateChange'>;
runningWithClaimedKeys: boolean;
generateStatePromise: () => Promise<AuthObject | null>;
generateNonce: () => Promise<string>;
}>;

export const KeylessProvider = async (props: KeylessProviderProps) => {
const { rest, runningWithClaimedKeys, generateNonce, generateStatePromise, 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')
Expand All @@ -56,8 +53,6 @@ export const KeylessProvider = async (props: KeylessProviderProps) => {
return (
<ClientClerkProvider
{...mergeNextClerkPropsWithEnv(rest)}
nonce={await generateNonce()}
initialState={await generateStatePromise()}
disableKeyless
>
{children}
Expand All @@ -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={await generateNonce()}
initialState={await generateStatePromise()}
>
{children}
</ClientClerkProvider>
Expand Down
78 changes: 0 additions & 78 deletions packages/nextjs/src/client-boundary/PromisifiedAuthProvider.tsx

This file was deleted.

3 changes: 1 addition & 2 deletions packages/nextjs/src/client-boundary/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

export {
useAuth,
useClerk,
useEmailLink,
useOrganization,
Expand All @@ -23,5 +24,3 @@ export {
EmailLinkErrorCode,
EmailLinkErrorCodeStatus,
} from '@clerk/react/errors';

export { usePromisifiedAuth as useAuth } from './PromisifiedAuthProvider';
23 changes: 0 additions & 23 deletions packages/react/src/contexts/AuthContext.ts

This file was deleted.

92 changes: 92 additions & 0 deletions packages/react/src/contexts/AuthContext.tsx
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should likely be renamed now that it doesn't actually contain a context.

Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { DeriveStateReturnType } from '@clerk/shared/deriveState';
import { deriveFromClientSideState, deriveFromSsrInitialState } from '@clerk/shared/deriveState';
import { useClerkInstanceContext, useInitialStateContext } from '@clerk/shared/react';
import type {
ActClaim,
ClientResource,
JwtPayload,
OrganizationCustomPermissionKey,
OrganizationCustomRoleKey,
SessionStatusClaim,
} from '@clerk/shared/types';
import { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react';

type AuthStateValue = {
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 defaultDerivedInitialState = {
actor: undefined,
factorVerificationAge: null,
orgId: undefined,
orgPermissions: undefined,
orgRole: undefined,
orgSlug: undefined,
sessionClaims: undefined,
sessionId: undefined,
sessionStatus: undefined,
userId: undefined,
};

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 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]);

const authState = useSyncExternalStore(
useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]),
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 <Suspense> 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);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a common pattern for all the hooks and is a breaking change. Doing this by default in the react package might introduce transitions into apps that don't have them currently, which might cause problems, so we'll likely want to find a way to introduce this incrementally/optionally and let the framework level decide.

This does not by itself get rid of the transitive state. To do that safely we probably have to start the transition ourselves from inside clerk-js so we can make sure it wraps both the navigation and the state emit.

}

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,
};
}
Loading
Loading