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..48cb59b7fee --- /dev/null +++ b/integration/templates/next-app-router/src/app/transitions/page.tsx @@ -0,0 +1,167 @@ +'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 53ad10a5aa0..3a2dd7dd2c7 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}`); + }); +}); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index e529e6d55e1..4b181df6f80 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -81,6 +81,7 @@ import type { InstanceType, JoinWaitlistParams, ListenerCallback, + ListenerOptions, NavigateOptions, OrganizationListProps, OrganizationProfileProps, @@ -491,8 +492,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 +601,7 @@ export class Clerk implements ClerkInterface { return; } - this.#setAccessors(); - this.#emit(); + this.#updateAccessors(); await onAfterSetActive(); }; @@ -1570,8 +1569,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) @@ -1583,11 +1581,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, @@ -2486,14 +2484,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.__internal_onAfterSetActive === 'function' @@ -2506,7 +2506,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()) { @@ -2925,15 +2928,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); } } }; @@ -2956,21 +2962,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 = () => { 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) diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index 262a0c68b80..e4363e71c65 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -1,6 +1,7 @@ 'use client'; import { ClerkProvider as ReactClerkProvider } from '@clerk/react'; import type { Ui } from '@clerk/react/internal'; +import { InitialStateProvider } from '@clerk/shared/react'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; import React from 'react'; @@ -38,12 +39,6 @@ const NextClientClerkProvider = (props: NextClerkProviderPr } }, []); - // 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.__internal_onBeforeSetActive = intent => { /** @@ -115,6 +110,16 @@ export const ClientClerkProvider = ( 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}; } diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index ecb49a099f0..f3c015f57f7 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -1,10 +1,8 @@ import type { Ui } from '@clerk/react/internal'; 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'; @@ -33,28 +31,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: (await statePromiseOrValue) as InitialState | undefined, + nonce: await noncePromiseOrValue, }); const { shouldRunAsKeyless, runningWithClaimedKeys } = await getKeylessStatus(propsWithEnvs); - let output: ReactNode; - try { const detectKeylessEnvDrift = await import('../../server/keyless-telemetry.js').then( mod => mod.detectKeylessEnvDrift, @@ -65,35 +52,15 @@ export async function ClerkProvider( } if (shouldRunAsKeyless) { - output = ( + return ( {children} ); - } else { - output = ( - - {children} - - ); } - if (dynamic) { - return ( - // TODO: fix types so AuthObject is compatible with InitialState - }> - {output} - - ); - } - return output; + 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 7ca10997bea..ebf42687166 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'; @@ -14,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) { @@ -33,14 +32,12 @@ export async function getKeylessStatus( } type KeylessProviderProps = PropsWithChildren<{ - rest: Without; + rest: Without; runningWithClaimedKeys: boolean; - generateStatePromise: () => Promise; - generateNonce: () => Promise; }>; 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') @@ -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={await generateNonce()} - initialState={await generateStatePromise()} > {children} 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'; 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'>; }; 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..08322b2d4d2 --- /dev/null +++ b/packages/react/src/contexts/AuthContext.tsx @@ -0,0 +1,86 @@ +import type { DeriveStateReturnType } from '@clerk/shared/deriveState'; +import { deriveFromClientSideState, deriveFromSsrInitialState } from '@clerk/shared/deriveState'; +import { useClerkInstanceContext, useInitialStateContext } from '@clerk/shared/react'; +import type { + ActClaim, + InitialState, + JwtPayload, + OrganizationCustomPermissionKey, + OrganizationCustomRoleKey, + Resources, + SessionStatusClaim, +} from '@clerk/shared/types'; +import { useCallback, 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 initialState = useInitialStateContext(); + const getInitialState = useCallback(() => initialState, [initialState]); + + const state = useSyncExternalStore( + useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), + useCallback(() => { + if (!clerk.loaded || !clerk.__internal_lastEmittedResources) { + return getInitialState(); + } + + return clerk.__internal_lastEmittedResources; + }, [clerk, getInitialState]), + getInitialState, + ); + + const authState = useMemo(() => { + if (!state) { + return defaultDerivedInitialState; + } + const fullState = isInitialState(state) ? deriveFromSsrInitialState(state) : deriveFromClientSideState(state); + return authStateFromFull(fullState); + }, [state]); + + return authState; +} + +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, + }; +} + +function isInitialState(state: Resources | InitialState): state is InitialState { + return !('client' in state); +} diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx deleted file mode 100644 index 6d507da4b6c..00000000000 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ /dev/null @@ -1,137 +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 { AuthContext } 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 derivedState = deriveState(clerk.loaded, state, initialState); - 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 { - 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 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.__internal_updateProps({ appearance: options.appearance }); - }, [options.appearance]); - - React.useEffect(() => { - void isomorphicClerkRef.current.__internal_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 351ee6f2b5f..1881e7345dc 100644 --- a/packages/react/src/contexts/ClerkProvider.tsx +++ b/packages/react/src/contexts/ClerkProvider.tsx @@ -1,20 +1,25 @@ -import type { IsomorphicClerkOptions } from '@clerk/shared/types'; +import { ClerkContextProvider } from '@clerk/shared/react'; import type { Ui } from '@clerk/ui/internal'; import React from 'react'; 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, ...restIsomorphicClerkOptions } = props; - const isomorphicClerkOptions = restIsomorphicClerkOptions as unknown as IsomorphicClerkOptions; + + const { isomorphicClerk, clerkStatus } = useLoadedIsomorphicClerk( + restIsomorphicClerkOptions as unknown as IsomorphicClerkOptions, + ); return ( {children} @@ -26,3 +31,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.__internal_updateProps({ appearance: options.appearance }); + }, [options.appearance]); + + React.useEffect(() => { + void isomorphicClerkRef.current.__internal_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/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/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/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/react/src/hooks/__tests__/useAuth.test.tsx b/packages/react/src/hooks/__tests__/useAuth.test.tsx index fcffe1bdc17..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 } from '../../contexts/AuthContext'; import { errorThrower } from '../../errors/errorThrower'; import { invalidStateError } from '../../errors/messages'; import { useAuth, useDerivedAuth } from '../useAuth'; @@ -69,10 +68,10 @@ describe('useAuth', () => { test('renders the correct values when wrapped in ', () => { expect(() => { render( - - + + - + , ); }).not.toThrow(); 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 8d0a29948a0..7675b1bb163 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 { useAuthState } from '../contexts/AuthContext'; import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext'; import { errorThrower } from '../errors/errorThrower'; import { invalidStateError } from '../errors/messages'; @@ -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 authContextFromHook = useAuthContext(); - let authContext = authContextFromHook; - - if (authContext.sessionId === undefined && authContext.userId === undefined) { - authContext = initialAuthState != null ? initialAuthState : {}; - } + const { treatPendingAsSignedOut } = options ?? {}; + const authState = useAuthState(); const isomorphicClerk = useIsomorphicClerkContext(); const getToken: GetToken = useCallback(createGetToken(isomorphicClerk), [isomorphicClerk]); @@ -113,7 +106,7 @@ export const useAuth = (initialAuthStateOrOptions: UseAuthOptions = {}): UseAuth return useDerivedAuth( { - ...authContext, + ...authState, getToken, signOut, }, diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 7328457558d..55ed411e9ff 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -32,6 +32,7 @@ import type { HandleOAuthCallbackParams, JoinWaitlistParams, ListenerCallback, + ListenerOptions, LoadedClerk, OrganizationListProps, OrganizationProfileProps, @@ -39,6 +40,7 @@ import type { OrganizationSwitcherProps, PricingTableProps, RedirectOptions, + Resources, SetActiveParams, SignInProps, SignInRedirectOptions, @@ -158,8 +160,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> = []; @@ -582,8 +587,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 => { @@ -802,6 +807,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. */ @@ -1254,18 +1263,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/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; diff --git a/packages/shared/src/react/ClerkContextProvider.tsx b/packages/shared/src/react/ClerkContextProvider.tsx new file mode 100644 index 00000000000..05f325c6acc --- /dev/null +++ b/packages/shared/src/react/ClerkContextProvider.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import type { Clerk, ClerkStatus, InitialState, LoadedClerk } from '../types'; +import { + __experimental_CheckoutProvider as CheckoutProvider, + ClerkInstanceContext, + InitialStateProvider, +} from './contexts'; +import { SWRConfigCompat } from './providers/SWRConfigCompat'; +import { assertClerkSingletonExists } from './utils'; + +type ClerkContextProps = { + clerk: Clerk; + clerkStatus?: ClerkStatus; + children: React.ReactNode; + swrConfig?: any; + initialState?: InitialState; +}; + +export function ClerkContextProvider(props: ClerkContextProps): JSX.Element | null { + const clerk = props.clerk as LoadedClerk; + + assertClerkSingletonExists(clerk); + + 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], + ); + + return ( + + + + + {props.children} + + + + + ); +} diff --git a/packages/shared/src/react/__tests__/payment-element.test.tsx b/packages/shared/src/react/__tests__/payment-element.test.tsx index 0cb8e1bd1b2..8499e757f00 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', () => ({ @@ -180,11 +180,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 => { @@ -200,15 +195,11 @@ describe('PaymentElement Localization', () => { return render( - - - - <__experimental_PaymentElementProvider checkout={mockCheckout}> - <__experimental_PaymentElement fallback={
Loading...
} /> - -
-
-
+ + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
, ); }; @@ -233,15 +224,11 @@ describe('PaymentElement Localization', () => { render( - - - - <__experimental_PaymentElementProvider checkout={mockCheckout}> - <__experimental_PaymentElement fallback={
Loading...
} /> - -
-
-
+ + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
, ); @@ -275,15 +262,11 @@ describe('PaymentElement Localization', () => { const { unmount } = render( - - - - <__experimental_PaymentElementProvider checkout={mockCheckout}> - <__experimental_PaymentElement fallback={
Loading...
} /> - -
-
-
+ + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
, ); diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index 65abac89866..f848aca9026 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -1,28 +1,54 @@ 'use client'; import type { PropsWithChildren } from 'react'; -import React from 'react'; +import React, { useState } from 'react'; import type { BillingSubscriptionPlanPeriod, ClerkOptions, - ClientResource, ForPayerType, + InitialState, LoadedClerk, OrganizationResource, - SignedInSessionResource, - UserResource, } from '../types'; +import { useOrganizationBase } from './hooks/base/useOrganizationBase'; import { createContextAndHook } from './hooks/createContextAndHook'; -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 { 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', ); +export function InitialStateProvider({ + children, + initialState, +}: { + children: React.ReactNode; + 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, + // 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}; +} + +export function useInitialStateContext(): InitialState | undefined { + const initialState = _useInitialStateContext(); + + // 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 initialState; +} + const OptionsContext = React.createContext({}); /** @@ -62,35 +88,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 @@ -119,17 +120,10 @@ Learn more: https://clerk.com/docs/components/clerk-provider`.trim(), export { __experimental_CheckoutProvider, ClerkInstanceContext, - ClientContext, OptionsContext, - OrganizationProvider, - SessionContext, useAssertWrappedByClerkProvider, useCheckoutContext, useClerkInstanceContext, - useClientContext, useOptionsContext, useOrganizationContext, - UserContext, - useSessionContext, - useUserContext, }; 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..fd4a38b536b --- /dev/null +++ b/packages/shared/src/react/hooks/base/useClientBase.ts @@ -0,0 +1,24 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +import type { ClientResource } from '@/types'; + +import { useClerkInstanceContext } from '../../contexts'; + +const initialSnapshot = undefined; +const getInitialSnapshot = () => initialSnapshot; +export function useClientBase(): ClientResource | null | undefined { + const clerk = useClerkInstanceContext(); + + const client = useSyncExternalStore( + useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), + useCallback(() => { + if (!clerk.loaded) { + return initialSnapshot; + } + return clerk.client; + }, [clerk]), + getInitialSnapshot, + ); + + return client; +} 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..4d065766867 --- /dev/null +++ b/packages/shared/src/react/hooks/base/useOrganizationBase.ts @@ -0,0 +1,25 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +import type { OrganizationResource } from '@/types'; + +import { useClerkInstanceContext, useInitialStateContext } from '../../contexts'; + +export function useOrganizationBase(): OrganizationResource | null | undefined { + const clerk = useClerkInstanceContext(); + 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 getInitialState(); + } + return clerk.organization; + }, [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 new file mode 100644 index 00000000000..898fe7f3813 --- /dev/null +++ b/packages/shared/src/react/hooks/base/useSessionBase.ts @@ -0,0 +1,26 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +import type { SignedInSessionResource } from '@/types'; + +import { useClerkInstanceContext, useInitialStateContext } from '../../contexts'; + +export function useSessionBase(): SignedInSessionResource | null | undefined { + const clerk = useClerkInstanceContext(); + const initialState = useInitialStateContext(); + const getInitialState = useCallback(() => { + return initialState?.session as SignedInSessionResource | undefined; + }, [initialState?.session]); + + const session = useSyncExternalStore( + useCallback(callback => clerk.addListener(callback, { skipInitialEmit: true }), [clerk]), + useCallback(() => { + if (!clerk.loaded) { + return getInitialState(); + } + return clerk.session; + }, [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 new file mode 100644 index 00000000000..dfe393f91bc --- /dev/null +++ b/packages/shared/src/react/hooks/base/useUserBase.ts @@ -0,0 +1,29 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +import type { UserResource } from '@/types'; + +import { useClerkInstanceContext, useInitialStateContext } from '../../contexts'; + +export function useUserBase(): UserResource | null | undefined { + const clerk = useClerkInstanceContext(); + const initialState = useInitialStateContext(); + const getInitialState = useCallback(() => initialState?.user, [initialState?.user]); + + const user = useSyncExternalStore( + useCallback( + callback => { + return clerk.addListener(callback, { skipInitialEmit: true }); + }, + [clerk], + ), + useCallback(() => { + if (!clerk.loaded) { + return getInitialState(); + } + return clerk.user; + }, [clerk, getInitialState]), + getInitialState, + ); + + return user; +} diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index b05c94db6bd..725df859184 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -4,19 +4,19 @@ export type { UseSubscriptionParams } from './hooks/useSubscription.types'; export { ClerkInstanceContext, - ClientContext, OptionsContext, - OrganizationProvider, - SessionContext, useAssertWrappedByClerkProvider, useClerkInstanceContext, useClientContext, useOptionsContext, useOrganizationContext, - UserContext, useSessionContext, useUserContext, __experimental_CheckoutProvider, + InitialStateProvider, + useInitialStateContext, } from './contexts'; +export { ClerkContextProvider } from './ClerkContextProvider'; + export * from './billing/payment-element'; 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/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index d83d5db8cfb..e860bacdbbc 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -135,6 +135,7 @@ export type SDKMetadata = { }; export type ListenerCallback = (emission: Resources) => void; +export type ListenerOptions = { skipInitialEmit?: boolean }; export type UnsubscribeCallback = () => void; export type SetActiveNavigate = ({ session }: { session: SessionResource }) => void | Promise; @@ -245,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. @@ -690,7 +698,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. diff --git a/packages/ui/src/contexts/CoreClerkContextWrapper.tsx b/packages/ui/src/contexts/CoreClerkContextWrapper.tsx deleted file mode 100644 index 85e451a784d..00000000000 --- a/packages/ui/src/contexts/CoreClerkContextWrapper.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { - __experimental_CheckoutProvider as CheckoutProvider, - ClerkInstanceContext, - ClientContext, - OrganizationProvider, - SessionContext, - UserContext, -} from '@clerk/shared/react'; -import type { Clerk, LoadedClerk, Resources } from '@clerk/shared/types'; -import React from 'react'; - -import { assertClerkSingletonExists } from './utils'; - -type CoreClerkContextWrapperProps = { - clerk: Clerk; - children: React.ReactNode; - swrConfig?: any; -}; - -type CoreClerkContextProviderState = Resources; - -export function CoreClerkContextWrapper(props: CoreClerkContextWrapperProps): JSX.Element | null { - // TODO: Revise Clerk and LoadedClerk - 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, - }); - - React.useEffect(() => { - 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 sessionCtx = React.useMemo(() => ({ value: session }), [session]); - const userCtx = React.useMemo(() => ({ value: user }), [user]); - const organizationCtx = React.useMemo( - () => ({ - value: { organization: organization }, - }), - [organization], - ); - - return ( - - - - - - - {props.children} - - - - - - - ); -} 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; } diff --git a/packages/ui/src/contexts/index.ts b/packages/ui/src/contexts/index.ts index 399cdf74a7e..d669a4f31e2 100644 --- a/packages/ui/src/contexts/index.ts +++ b/packages/ui/src/contexts/index.ts @@ -4,6 +4,6 @@ export * from './EnvironmentContext'; export * from './OptionsContext'; export * from './CoreSessionContext'; export * from './CoreClientContext'; -export * from './CoreClerkContextWrapper'; export * from './AcceptedUserInvitations'; export * from './ModuleManagerContext'; +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 22ac1112b91..8af824cde86 100644 --- a/packages/ui/src/lazyModules/providers.tsx +++ b/packages/ui/src/lazyModules/providers.tsx @@ -10,7 +10,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 ModuleManagerProvider = lazy(() => import('../contexts').then(m => ({ default: m.ModuleManagerProvider }))); @@ -49,11 +49,11 @@ export const LazyProviders = (props: LazyProvidersProps) => { cssLayerName={props.options.appearance?.cssLayerName} > - + {props.children} - + );