From 1921ede6c5975c0f731e9dc0572826868cfdd922 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Mon, 17 Mar 2025 13:20:44 -0300 Subject: [PATCH 01/14] Introduce tests --- integration/testUtils/index.ts | 9 +++++ integration/testUtils/organizationsService.ts | 27 +++++++++++++++ .../testUtils/sessionTaskPageObject.ts | 26 ++++++++++++++ .../tests/session-tasks-sign-in.test.ts | 17 ++++++++-- .../tests/session-tasks-sign-up.test.ts | 34 ++++++++++++++----- packages/clerk-js/src/core/clerk.ts | 8 ++--- 6 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 integration/testUtils/organizationsService.ts create mode 100644 integration/testUtils/sessionTaskPageObject.ts diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index 7523027cd72..b5a6c6fff0d 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -7,7 +7,9 @@ import { createAppPageObject } from './appPageObject'; import { createEmailService } from './emailService'; import { createInvitationService } from './invitationsService'; import { createKeylessPopoverPageObject } from './keylessPopoverPageObject'; +import { createOrganizationsService } from './organizationsService'; import { createOrganizationSwitcherComponentPageObject } from './organizationSwitcherPageObject'; +import { createSessionTaskComponentPageObject } from './sessionTaskPageObject'; import type { EnchancedPage, TestArgs } from './signInPageObject'; import { createSignInComponentPageObject } from './signInPageObject'; import { createSignUpComponentPageObject } from './signUpPageObject'; @@ -50,6 +52,11 @@ const createExpectPageObject = ({ page }: TestArgs) => { return !!window.Clerk?.user; }); }, + toHaveCurrentTask: async () => { + return page.waitForFunction(() => { + return !window.Clerk?.session?.currentTask; + }); + }, }; }; @@ -87,6 +94,7 @@ export const createTestUtils = < email: createEmailService(), users: createUserService(clerkClient), invitations: createInvitationService(clerkClient), + organizations: createOrganizationsService(clerkClient), clerk: clerkClient, }; @@ -106,6 +114,7 @@ export const createTestUtils = < userButton: createUserButtonPageObject(testArgs), userVerification: createUserVerificationComponentPageObject(testArgs), waitlist: createWaitlistComponentPageObject(testArgs), + sessionTask: createSessionTaskComponentPageObject(testArgs), expect: createExpectPageObject(testArgs), clerk: createClerkUtils(testArgs), }; diff --git a/integration/testUtils/organizationsService.ts b/integration/testUtils/organizationsService.ts new file mode 100644 index 00000000000..2566e75d28d --- /dev/null +++ b/integration/testUtils/organizationsService.ts @@ -0,0 +1,27 @@ +import type { ClerkClient, Organization } from '@clerk/backend'; +import { faker } from '@faker-js/faker'; + +export type FakeOrganization = Pick; + +export type OrganizationService = { + deleteAll: () => Promise; + createFakeOrganization: () => FakeOrganization; +}; + +export const createOrganizationsService = (clerkClient: ClerkClient) => { + const self: OrganizationService = { + createFakeOrganization: () => ({ + slug: faker.helpers.slugify(faker.commerce.department()).toLowerCase(), + name: faker.commerce.department(), + }), + deleteAll: async () => { + const organizations = await clerkClient.organizations.getOrganizationList(); + + const bulkDeletionPromises = organizations.data.map(({ id }) => clerkClient.organizations.deleteOrganization(id)); + + await Promise.all(bulkDeletionPromises); + }, + }; + + return self; +}; diff --git a/integration/testUtils/sessionTaskPageObject.ts b/integration/testUtils/sessionTaskPageObject.ts new file mode 100644 index 00000000000..a9a3d83a857 --- /dev/null +++ b/integration/testUtils/sessionTaskPageObject.ts @@ -0,0 +1,26 @@ +import { expect } from '@playwright/test'; + +import { common } from './commonPageObject'; +import type { FakeOrganization } from './organizationsService'; +import type { TestArgs } from './signInPageObject'; + +export const createSessionTaskComponentPageObject = (testArgs: TestArgs) => { + const { page } = testArgs; + + const self = { + ...common(testArgs), + resolveForceOrganizationSelectionTask: async (fakeOrganization: FakeOrganization) => { + const createOrganizationButton = page.getByRole('button', { name: /create organization/i }); + + await expect(createOrganizationButton).toBeVisible(); + expect(page.url()).toContain('add-organization'); + + await page.locator('input[name=name]').fill(fakeOrganization.name); + await page.locator('input[name=slug]').fill(fakeOrganization.slug); + + await createOrganizationButton.click(); + }, + }; + + return self; +}; diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts index eebc35002c1..666920898e3 100644 --- a/integration/tests/session-tasks-sign-in.test.ts +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -1,8 +1,9 @@ -import { expect, test } from '@playwright/test'; +import { test } from '@playwright/test'; import { appConfigs } from '../presets'; import type { FakeUser } from '../testUtils'; import { createTestUtils, testAgainstRunningApps } from '../testUtils'; +import type { FakeOrganization } from '../testUtils/organizationsService'; testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( 'session tasks after sign-in flow @nextjs', @@ -10,20 +11,26 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( test.describe.configure({ mode: 'serial' }); let fakeUser: FakeUser; + let fakeOrganization: FakeOrganization; test.beforeAll(async () => { const u = createTestUtils({ app }); fakeUser = u.services.users.createFakeUser(); + fakeOrganization = u.services.organizations.createFakeOrganization(); await u.services.users.createBapiUser(fakeUser); }); test.afterAll(async () => { + const u = createTestUtils({ app }); await fakeUser.deleteIfExists(); + await u.services.organizations.deleteAll(); await app.teardown(); }); test('navigate to task on after sign-in', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); + + // Performs sign-in await u.po.signIn.goTo(); await u.po.signIn.setIdentifier(fakeUser.email); await u.po.signIn.continue(); @@ -31,8 +38,12 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( await u.po.signIn.continue(); await u.po.expect.toBeSignedIn(); - await expect(u.page.getByRole('button', { name: /create organization/i })).toBeVisible(); - expect(page.url()).toContain('add-organization'); + // Resolves task + await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization); + await u.po.expect.toHaveCurrentTask(); + + // Navigates to after sign-in + await u.page.waitForAppUrl('/'); }); }, ); diff --git a/integration/tests/session-tasks-sign-up.test.ts b/integration/tests/session-tasks-sign-up.test.ts index ad5003eb2f3..7034cf50475 100644 --- a/integration/tests/session-tasks-sign-up.test.ts +++ b/integration/tests/session-tasks-sign-up.test.ts @@ -1,34 +1,50 @@ -import { expect, test } from '@playwright/test'; +import { test } from '@playwright/test'; import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; import { createTestUtils, testAgainstRunningApps } from '../testUtils'; +import type { FakeOrganization } from '../testUtils/organizationsService'; testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( 'session tasks after sign-up flow @nextjs', ({ app }) => { test.describe.configure({ mode: 'serial' }); + let fakeUser: FakeUser; + let fakeOrganization: FakeOrganization; + + test.beforeAll(() => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPhoneNumber: true, + withUsername: true, + }); + fakeOrganization = u.services.organizations.createFakeOrganization(); + }); + test.afterAll(async () => { + const u = createTestUtils({ app }); + await u.services.organizations.deleteAll(); + await fakeUser.deleteIfExists(); await app.teardown(); }); test('navigate to task on after sign-up', async ({ page, context }) => { + // Performs sign-up const u = createTestUtils({ app, page, context }); - const fakeUser = u.services.users.createFakeUser({ - fictionalEmail: true, - withPhoneNumber: true, - withUsername: true, - }); await u.po.signUp.goTo(); await u.po.signUp.signUpWithEmailAndPassword({ email: fakeUser.email, password: fakeUser.password, }); - await expect(u.page.getByRole('button', { name: /create organization/i })).toBeVisible(); - expect(page.url()).toContain('add-organization'); + // Resolves task + await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization); + await u.po.expect.toHaveCurrentTask(); - await fakeUser.deleteIfExists(); + // Navigates to after sign-up + await u.page.waitForAppUrl('/'); }); }, ); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 4960f3f2fc4..cfb6cbe8a1e 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -44,6 +44,7 @@ import type { OrganizationProfileProps, OrganizationResource, OrganizationSwitcherProps, + PendingSessionResource, PublicKeyCredentialCreationOptionsWithoutExtensions, PublicKeyCredentialRequestOptionsWithoutExtensions, PublicKeyCredentialWithAuthenticatorAssertionResponse, @@ -1069,16 +1070,15 @@ export class Clerk implements ClerkInterface { await onAfterSetActive(); }; - #handlePendingSession = async (session: SignedInSessionResource) => { + #handlePendingSession = async (session: PendingSessionResource) => { if (!this.environment) { return; } - // Handles multi-session scenario when switching from `active` - // to `pending` + // Handles multi-session scenario when switching between `pending` sessions if (inActiveBrowserTab() || !this.#options.standardBrowser) { await this.#touchCurrentSession(session); - session = this.#getSessionFromClient(session.id) ?? session; + session = (this.#getSessionFromClient(session.id) as PendingSessionResource) ?? session; } // Syncs __session and __client_uat, in case the `pending` session From 576509ef5c9fbeae4c9e03a6838cf56119def2c7 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Mon, 17 Mar 2025 15:45:00 -0300 Subject: [PATCH 02/14] Forward task completion URL to `SessionTask` component --- integration/testUtils/organizationsService.ts | 2 -- .../ui/components/SessionTask/SessionTask.tsx | 36 ++++++++++++++++--- .../src/ui/components/SignIn/SignIn.tsx | 10 ++++-- .../src/ui/components/SignUp/SignUp.tsx | 5 ++- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/integration/testUtils/organizationsService.ts b/integration/testUtils/organizationsService.ts index 2566e75d28d..cf1f7f29001 100644 --- a/integration/testUtils/organizationsService.ts +++ b/integration/testUtils/organizationsService.ts @@ -16,9 +16,7 @@ export const createOrganizationsService = (clerkClient: ClerkClient) => { }), deleteAll: async () => { const organizations = await clerkClient.organizations.getOrganizationList(); - const bulkDeletionPromises = organizations.data.map(({ id }) => clerkClient.organizations.deleteOrganization(id)); - await Promise.all(bulkDeletionPromises); }, }; diff --git a/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx b/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx index dbfed04a19c..03710860edd 100644 --- a/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx +++ b/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx @@ -1,10 +1,18 @@ -import { useClerk } from '@clerk/shared/react/index'; +import { useClerk } from '@clerk/shared/react'; import { eventComponentMounted } from '@clerk/shared/telemetry'; import type { SessionTask } from '@clerk/types'; +import { useCallback, useEffect } from 'react'; import { OrganizationListContext } from '../../contexts'; +import { SessionTaskContext as SessionTaskContext } from '../../contexts/components/SessionTask'; +import { useRouter } from '../../router'; import { OrganizationList } from '../OrganizationList'; +interface SessionTaskProps { + task: SessionTask['key']; + redirectUrlComplete: string; +} + const ContentRegistry: Record = { org: () => ( = { /** * @internal */ -export function SessionTask({ task }: { task: SessionTask['key'] }): React.ReactNode { - const clerk = useClerk(); +export function SessionTask({ task, redirectUrlComplete }: SessionTaskProps): React.ReactNode { + const { session, telemetry, __experimental_nextTask } = useClerk(); + const { navigate } = useRouter(); + + useEffect(() => { + if (session?.currentTask) { + return; + } + + void navigate(redirectUrlComplete); + }, [session?.currentTask, navigate, redirectUrlComplete]); + + telemetry?.record(eventComponentMounted('SessionTask', { task })); - clerk.telemetry?.record(eventComponentMounted('SessionTask', { task })); + const nextTask = useCallback( + () => __experimental_nextTask({ redirectUrlComplete }), + [__experimental_nextTask, redirectUrlComplete], + ); const Content = ContentRegistry[task]; - return ; + return ( + + + + ); } diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index 2a5895836f0..490d18c2ad7 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -132,7 +132,10 @@ function SignInRoutes(): JSX.Element { {signInContext.withSessionTasks && ( - + )} @@ -146,7 +149,10 @@ function SignInRoutes(): JSX.Element { )} {signInContext.withSessionTasks && ( - + )} diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx index 9e42f7efac8..d2c099bc909 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx @@ -91,7 +91,10 @@ function SignUpRoutes(): JSX.Element { {signUpContext.withSessionTasks && ( - + )} From f14c14badac2c3bace3a7ca0b0d1f924340abb2f Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Mon, 17 Mar 2025 17:35:11 -0300 Subject: [PATCH 03/14] Introduce `__experimental_nextTask` --- .../clerk-js/src/core/__tests__/clerk.test.ts | 10 +++++++ packages/clerk-js/src/core/clerk.ts | 30 +++++++++++++++++++ .../OrganizationList/OrganizationListPage.tsx | 3 +- .../OrganizationList/UserMembershipList.tsx | 7 ++++- packages/types/src/clerk.ts | 26 ++++++++++++++++ 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index ea573685bd0..d5b1a33f52f 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2258,4 +2258,14 @@ describe('Clerk singleton', () => { }); }); }); + + describe('nextTask', () => { + describe('with pending session', () => { + it.todo('navigates to next task'); + }); + + describe('with active session', () => { + it.todo('navigates to final destination url'); + }); + }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index cfb6cbe8a1e..fb6859079e3 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -40,6 +40,7 @@ import type { JoinWaitlistParams, ListenerCallback, NavigateOptions, + NextTaskParams, OrganizationListProps, OrganizationProfileProps, OrganizationResource, @@ -1101,6 +1102,35 @@ export class Clerk implements ClerkInterface { this.#emit(); }; + public __experimental_nextTask = async ({ redirectUrlComplete }: NextTaskParams): Promise => { + if (!this.session || !this.environment) { + return; + } + + // Refresh session state to ensure latest status + const session = await this.session.reload(); + + // TypeScript requires this check for type narrowing, but this case cannot occur + if (!session) { + return; + } + + if (session.status === 'pending') { + await navigateToTask(session.currentTask, { + globalNavigate: this.navigate, + componentNavigationContext: this.#componentNavigationContext, + options: this.#options, + environment: this.environment, + }); + + return; + } + + const defaultRedirectUrlComplete = this.client?.signUp ? this.buildAfterSignUpUrl() : this.buildAfterSignUpUrl(); + + await this.navigate(redirectUrlComplete ?? defaultRedirectUrlComplete); + }; + public addListener = (listener: ListenerCallback): UnsubscribeCallback => { listener = memoizeListenerCallback(listener); this.#listeners.push(listener); diff --git a/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx b/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx index 8e6ec776c26..e83fbb16742 100644 --- a/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx @@ -109,7 +109,7 @@ export const OrganizationListPage = withCardStateProvider(() => { }); const OrganizationListFlows = ({ showListInitially }: { showListInitially: boolean }) => { - const { navigateAfterCreateOrganization, skipInvitationScreen, hideSlug } = useOrganizationListContext(); + const { navigateAfterCreateOrganization, skipInvitationScreen, hideSlug, onComplete } = useOrganizationListContext(); const [isCreateOrganizationFlow, setCreateOrganizationFlow] = useState(!showListInitially); return ( <> @@ -125,6 +125,7 @@ const OrganizationListFlows = ({ showListInitially }: { showListInitially: boole > diff --git a/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx b/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx index 5d5ed4149c8..8b96c7f6c36 100644 --- a/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx @@ -8,7 +8,7 @@ import { OrganizationListPreviewButton, sharedMainIdentifierSx } from './shared' export const MembershipPreview = withCardStateProvider((props: { organization: OrganizationResource }) => { const card = useCardState(); - const { navigateAfterSelectOrganization } = useOrganizationListContext(); + const { navigateAfterSelectOrganization, onComplete } = useOrganizationListContext(); const { isLoaded, setActive } = useOrganizationList(); if (!isLoaded) { @@ -19,6 +19,11 @@ export const MembershipPreview = withCardStateProvider((props: { organization: O await setActive({ organization, }); + + if (onComplete) { + return onComplete(); + } + await navigateAfterSelectOrganization(organization); }); }; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 2127800578f..c1fd478ab55 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -646,6 +646,14 @@ export interface Clerk { joinWaitlist: (params: JoinWaitlistParams) => Promise; + /** + * Navigates to the next task or redirects to completion URL. + * If the current session has pending tasks, it navigates to the next task. + * If all tasks are complete, it navigates to the provided completion URL. + * @experimental + */ + __experimental_nextTask: (params: NextTaskParams) => Promise; + /** * This is an optional function. * This function is used to load cached Client and Environment resources if Clerk fails to load them from the Frontend API. @@ -1196,6 +1204,11 @@ export type OrganizationProfileProps = RoutingOptions & { export type OrganizationProfileModalProps = WithoutRouting; export type CreateOrganizationProps = RoutingOptions & { + /** + * Callback function triggered after successfully creating a new organization + */ + onComplete?: () => void; + /** * Full URL or path to navigate after creating a new organization. * @default undefined @@ -1389,6 +1402,11 @@ export type OrganizationSwitcherProps = CreateOrganizationMode & }; export type OrganizationListProps = { + /** + * Callback function triggered after successfully selecting a new organization + */ + onComplete?: () => void; + /** * Full URL or path to navigate after creating a new organization. * @default undefined @@ -1582,6 +1600,14 @@ export interface AuthenticateWithGoogleOneTapParams { legalAccepted?: boolean; } +export interface NextTaskParams { + /** + * Full URL or path to navigate after successful resolving all tasks + * @default undefined + */ + redirectUrlComplete?: string; +} + export interface LoadedClerk extends Clerk { client: ClientResource; } From f2844b9f0e1699ad9262315538e75b16ec63287c Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Mon, 17 Mar 2025 18:30:53 -0300 Subject: [PATCH 04/14] Add unit tests --- .../clerk-js/src/core/__tests__/clerk.test.ts | 104 ++++++++++++++++-- packages/clerk-js/src/core/clerk.ts | 2 +- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index d5b1a33f52f..d127b5431e9 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1,5 +1,6 @@ import type { ActiveSessionResource, + PendingSessionResource, SignedInSessionResource, SignInJSON, SignUpJSON, @@ -486,6 +487,15 @@ describe('Clerk singleton', () => { lastActiveToken: { getRawString: () => 'mocked-token' }, tasks: [{ key: 'org' }], currentTask: { key: 'org', __internal_getUrl: () => 'https://foocorp.com/add-organization' }, + reload: jest.fn(() => + Promise.resolve({ + id: '1', + status: 'pending', + user: {}, + tasks: [{ key: 'org' }], + currentTask: { key: 'org', __internal_getUrl: () => 'https://foocorp.com/add-organization' }, + }), + ), }; let eventBusSpy; @@ -509,7 +519,7 @@ describe('Clerk singleton', () => { const sut = new Clerk(productionPublishableKey); await sut.load(); - await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + await sut.setActive({ session: mockSession as ActiveSessionResource }); expect(mockSession.touch).toHaveBeenCalled(); }); @@ -520,7 +530,7 @@ describe('Clerk singleton', () => { const sut = new Clerk(productionPublishableKey); await sut.load({ touchSession: false }); - await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + await sut.setActive({ session: mockSession as ActiveSessionResource }); await waitFor(() => { expect(mockSession.touch).not.toHaveBeenCalled(); expect(mockSession.getToken).toHaveBeenCalled(); @@ -534,7 +544,7 @@ describe('Clerk singleton', () => { const executionOrder: string[] = []; mockSession.touch.mockImplementationOnce(() => { - sut.session = mockSession as any; + sut.session = mockSession; executionOrder.push('session.touch'); return Promise.resolve(); }); @@ -2260,12 +2270,92 @@ describe('Clerk singleton', () => { }); describe('nextTask', () => { - describe('with pending session', () => { - it.todo('navigates to next task'); + describe('with `pending` session status', () => { + const mockSession = { + id: '1', + status: 'pending', + user: {}, + tasks: [{ key: 'org' }], + currentTask: { key: 'org', __internal_getUrl: () => 'https://foocorp.com/add-organization' }, + lastActiveToken: { getRawString: () => 'mocked-token' }, + }; + + const mockResource = { + ...mockSession, + remove: jest.fn(), + touch: jest.fn(() => Promise.resolve()), + getToken: jest.fn(), + reload: jest.fn(() => Promise.resolve(mockSession)), + }; + + beforeAll(() => { + mockResource.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockResource] })); + }); + + afterEach(() => { + mockResource.remove.mockReset(); + mockResource.touch.mockReset(); + }); + + it('navigates to next task', async () => { + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + await sut.setActive({ session: mockResource as any as PendingSessionResource }); + + await sut.__experimental_nextTask(); + + expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/add-organization'); + }); }); - describe('with active session', () => { - it.todo('navigates to final destination url'); + describe('with `active` session status', () => { + const mockSession = { + id: '1', + remove: jest.fn(), + status: 'active', + user: {}, + touch: jest.fn(() => Promise.resolve()), + getToken: jest.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + reload: jest.fn(() => + Promise.resolve({ + id: '1', + remove: jest.fn(), + status: 'active', + user: {}, + touch: jest.fn(() => Promise.resolve()), + getToken: jest.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + }), + ), + }; + let eventBusSpy; + + beforeEach(() => { + eventBusSpy = jest.spyOn(eventBus, 'dispatch'); + }); + + afterEach(() => { + mockSession.remove.mockReset(); + mockSession.touch.mockReset(); + eventBusSpy?.mockRestore(); + (window as any).__unstable__onBeforeSetActive = null; + (window as any).__unstable__onAfterSetActive = null; + }); + + it('navigates to redirect url on completion', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + + await sut.__experimental_nextTask(); + + expect(mockNavigate.mock.calls[0][0]).toBe('/'); + }); }); }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index fb6859079e3..4f204e0c324 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1102,7 +1102,7 @@ export class Clerk implements ClerkInterface { this.#emit(); }; - public __experimental_nextTask = async ({ redirectUrlComplete }: NextTaskParams): Promise => { + public __experimental_nextTask = async ({ redirectUrlComplete }: NextTaskParams = {}): Promise => { if (!this.session || !this.environment) { return; } From 3633f395ddd04e6a4477ba924bff4e4d16371741 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Mon, 17 Mar 2025 18:35:31 -0300 Subject: [PATCH 05/14] Add changeset --- .changeset/purple-balloons-join.md | 6 ++++++ packages/clerk-js/src/core/__tests__/clerk.test.ts | 12 +++--------- 2 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 .changeset/purple-balloons-join.md diff --git a/.changeset/purple-balloons-join.md b/.changeset/purple-balloons-join.md new file mode 100644 index 00000000000..ba1ff236632 --- /dev/null +++ b/.changeset/purple-balloons-join.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Introduce `__experimental_nextTask` method for navigating to next tasks on a after-auth flow diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index d127b5431e9..d1767a63115 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -519,7 +519,7 @@ describe('Clerk singleton', () => { const sut = new Clerk(productionPublishableKey); await sut.load(); - await sut.setActive({ session: mockSession as ActiveSessionResource }); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); expect(mockSession.touch).toHaveBeenCalled(); }); @@ -530,7 +530,7 @@ describe('Clerk singleton', () => { const sut = new Clerk(productionPublishableKey); await sut.load({ touchSession: false }); - await sut.setActive({ session: mockSession as ActiveSessionResource }); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); await waitFor(() => { expect(mockSession.touch).not.toHaveBeenCalled(); expect(mockSession.getToken).toHaveBeenCalled(); @@ -544,7 +544,7 @@ describe('Clerk singleton', () => { const executionOrder: string[] = []; mockSession.touch.mockImplementationOnce(() => { - sut.session = mockSession; + sut.session = mockSession as any; executionOrder.push('session.touch'); return Promise.resolve(); }); @@ -2330,16 +2330,10 @@ describe('Clerk singleton', () => { }), ), }; - let eventBusSpy; - - beforeEach(() => { - eventBusSpy = jest.spyOn(eventBus, 'dispatch'); - }); afterEach(() => { mockSession.remove.mockReset(); mockSession.touch.mockReset(); - eventBusSpy?.mockRestore(); (window as any).__unstable__onBeforeSetActive = null; (window as any).__unstable__onAfterSetActive = null; }); From 34c50dfd18d9b981063e49cf96f1377527a816c4 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Mon, 17 Mar 2025 18:47:55 -0300 Subject: [PATCH 06/14] Fix properties on isomorphic class --- .changeset/purple-balloons-join.md | 1 + packages/clerk-js/bundlewatch.config.json | 2 +- .../clerk-js/src/core/__tests__/clerk.test.ts | 7 +++- packages/clerk-js/src/core/clerk.ts | 38 ++++--------------- packages/clerk-js/src/core/sessionTasks.ts | 17 ++++----- .../OrganizationList/OrganizationListPage.tsx | 5 ++- .../OrganizationList/UserMembershipList.tsx | 6 +-- packages/react/src/isomorphicClerk.ts | 19 +++++++++- packages/types/src/clerk.ts | 33 +++++++--------- 9 files changed, 59 insertions(+), 69 deletions(-) diff --git a/.changeset/purple-balloons-join.md b/.changeset/purple-balloons-join.md index ba1ff236632..7cec7f23a69 100644 --- a/.changeset/purple-balloons-join.md +++ b/.changeset/purple-balloons-join.md @@ -1,5 +1,6 @@ --- '@clerk/clerk-js': patch +'@clerk/clerk-react': patch '@clerk/types': patch --- diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 978f4b8009a..3b2bf1010c2 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -2,7 +2,7 @@ "files": [ { "path": "./dist/clerk.js", "maxSize": "577.51kB" }, { "path": "./dist/clerk.browser.js", "maxSize": "78.5kB" }, - { "path": "./dist/clerk.headless.js", "maxSize": "51KB" }, + { "path": "./dist/clerk.headless.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "94KB" }, { "path": "./dist/vendors*.js", "maxSize": "30KB" }, { "path": "./dist/coinbase*.js", "maxSize": "35.5KB" }, diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index d1767a63115..c5e7fc51e8c 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2346,9 +2346,12 @@ describe('Clerk singleton', () => { await sut.load(mockedLoadOptions); await sut.setActive({ session: mockSession as any as ActiveSessionResource }); - await sut.__experimental_nextTask(); + const redirectUrlComplete = '/welcome-to-app'; + await sut.__experimental_nextTask({ redirectUrlComplete }); + + console.log(mockNavigate.mock.calls); - expect(mockNavigate.mock.calls[0][0]).toBe('/'); + expect(mockNavigate.mock.calls[0][0]).toBe('/welcome-to-app'); }); }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 4f204e0c324..af35482c1c1 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -15,6 +15,7 @@ import { handleValueOrFn, noop } from '@clerk/shared/utils'; import type { __experimental_CommerceNamespace, __experimental_PricingTableProps, + __internal_ComponentNavigationContext, __internal_UserVerificationModalProps, AuthenticateWithCoinbaseWalletParams, AuthenticateWithGoogleOneTapParams, @@ -203,15 +204,7 @@ export class Clerk implements ClerkInterface { #options: ClerkOptions = {}; #pageLifecycle: ReturnType | null = null; #touchThrottledUntil = 0; - #componentNavigationContext: { - navigate: ( - to: string, - options?: { - searchParams?: URLSearchParams; - }, - ) => Promise; - basePath: string; - } | null = null; + #componentNavigationContext: __internal_ComponentNavigationContext | null = null; public __internal_getCachedResources: | (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>) @@ -1103,31 +1096,22 @@ export class Clerk implements ClerkInterface { }; public __experimental_nextTask = async ({ redirectUrlComplete }: NextTaskParams = {}): Promise => { - if (!this.session || !this.environment) { - return; - } - - // Refresh session state to ensure latest status - const session = await this.session.reload(); - - // TypeScript requires this check for type narrowing, but this case cannot occur - if (!session) { + const session = await this.session?.reload(); + if (!session || !this.environment) { return; } if (session.status === 'pending') { await navigateToTask(session.currentTask, { - globalNavigate: this.navigate, - componentNavigationContext: this.#componentNavigationContext, options: this.#options, environment: this.environment, + globalNavigate: this.navigate, + componentNavigationContext: this.#componentNavigationContext, }); - return; } const defaultRedirectUrlComplete = this.client?.signUp ? this.buildAfterSignUpUrl() : this.buildAfterSignUpUrl(); - await this.navigate(redirectUrlComplete ?? defaultRedirectUrlComplete); }; @@ -1158,15 +1142,7 @@ export class Clerk implements ClerkInterface { return unsubscribe; }; - public __internal_setComponentNavigationContext = (context: { - navigate: ( - to: string, - options?: { - searchParams?: URLSearchParams; - }, - ) => Promise; - basePath: string; - }) => { + public __internal_setComponentNavigationContext = (context: __internal_ComponentNavigationContext) => { this.#componentNavigationContext = context; return () => (this.#componentNavigationContext = null); diff --git a/packages/clerk-js/src/core/sessionTasks.ts b/packages/clerk-js/src/core/sessionTasks.ts index 29d7d9e0481..074dbbcb657 100644 --- a/packages/clerk-js/src/core/sessionTasks.ts +++ b/packages/clerk-js/src/core/sessionTasks.ts @@ -1,4 +1,9 @@ -import type { ClerkOptions, EnvironmentResource, SessionTask } from '@clerk/types'; +import type { + __internal_ComponentNavigationContext, + ClerkOptions, + EnvironmentResource, + SessionTask, +} from '@clerk/types'; import { buildURL } from '../utils'; @@ -7,15 +12,7 @@ export const SESSION_TASK_ROUTE_BY_KEY: Record = { } as const; interface NavigateToTaskOptions { - componentNavigationContext: { - navigate: ( - to: string, - options?: { - searchParams?: URLSearchParams; - }, - ) => Promise; - basePath: string; - } | null; + componentNavigationContext: __internal_ComponentNavigationContext | null; globalNavigate: (to: string) => Promise; options: ClerkOptions; environment: EnvironmentResource; diff --git a/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx b/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx index e83fbb16742..c33c11e0df2 100644 --- a/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx @@ -109,7 +109,8 @@ export const OrganizationListPage = withCardStateProvider(() => { }); const OrganizationListFlows = ({ showListInitially }: { showListInitially: boolean }) => { - const { navigateAfterCreateOrganization, skipInvitationScreen, hideSlug, onComplete } = useOrganizationListContext(); + const { navigateAfterCreateOrganization, skipInvitationScreen, hideSlug, __internal_onOrganizationCreated } = + useOrganizationListContext(); const [isCreateOrganizationFlow, setCreateOrganizationFlow] = useState(!showListInitially); return ( <> @@ -125,7 +126,7 @@ const OrganizationListFlows = ({ showListInitially }: { showListInitially: boole > diff --git a/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx b/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx index 8b96c7f6c36..cded0ea87c1 100644 --- a/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx @@ -8,7 +8,7 @@ import { OrganizationListPreviewButton, sharedMainIdentifierSx } from './shared' export const MembershipPreview = withCardStateProvider((props: { organization: OrganizationResource }) => { const card = useCardState(); - const { navigateAfterSelectOrganization, onComplete } = useOrganizationListContext(); + const { navigateAfterSelectOrganization, __internal_onOrganizationSelected } = useOrganizationListContext(); const { isLoaded, setActive } = useOrganizationList(); if (!isLoaded) { @@ -20,8 +20,8 @@ export const MembershipPreview = withCardStateProvider((props: { organization: O organization, }); - if (onComplete) { - return onComplete(); + if (__internal_onOrganizationSelected) { + return __internal_onOrganizationSelected(); } await navigateAfterSelectOrganization(organization); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index c2b85093152..07188c77600 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -4,6 +4,7 @@ import { handleValueOrFn } from '@clerk/shared/utils'; import type { __experimental_CommerceNamespace, __experimental_PricingTableProps, + __internal_ComponentNavigationContext, __internal_UserVerificationModalProps, __internal_UserVerificationProps, AuthenticateWithCoinbaseWalletParams, @@ -23,6 +24,7 @@ import type { JoinWaitlistParams, ListenerCallback, LoadedClerk, + NextTaskParams, OrganizationListProps, OrganizationProfileProps, OrganizationResource, @@ -93,7 +95,6 @@ type IsomorphicLoadedClerk = Without< | '__internal_getCachedResources' | '__internal_reloadInitialResources' | '__experimental_commerce' - | '__internal_setComponentNavigationContext' > & { client: ClientResource | undefined; __experimental_commerce: __experimental_CommerceNamespace | undefined; @@ -612,6 +613,22 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + __experimental_nextTask = async (params: NextTaskParams): Promise => { + if (this.clerkjs) { + return this.clerkjs.__experimental_nextTask(params); + } else { + return Promise.reject(); + } + }; + + __internal_setComponentNavigationContext = (params: __internal_ComponentNavigationContext) => { + if (this.clerkjs) { + return this.clerkjs.__internal_setComponentNavigationContext(params); + } else { + return undefined; + } + }; + /** * `setActive` can be used to set the active session and/or organization. */ diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index c1fd478ab55..8ce1c237b3a 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -434,15 +434,7 @@ export interface Clerk { * be triggered from `Clerk` methods * @internal */ - __internal_setComponentNavigationContext: (context: { - navigate: ( - to: string, - options?: { - searchParams?: URLSearchParams; - }, - ) => Promise; - basePath: string; - }) => () => void; + __internal_setComponentNavigationContext: (context: __internal_ComponentNavigationContext) => () => void; /** * Set the active session and organization explicitly. @@ -1084,6 +1076,16 @@ export type __internal_UserVerificationProps = RoutingOptions & { export type __internal_UserVerificationModalProps = WithoutRouting<__internal_UserVerificationProps>; +export type __internal_ComponentNavigationContext = { + navigate: ( + to: string, + options?: { + searchParams?: URLSearchParams; + }, + ) => Promise; + basePath: string; +}; + type GoogleOneTapRedirectUrlProps = SignInForceRedirectUrl & SignUpForceRedirectUrl; export type GoogleOneTapProps = GoogleOneTapRedirectUrlProps & { @@ -1204,11 +1206,6 @@ export type OrganizationProfileProps = RoutingOptions & { export type OrganizationProfileModalProps = WithoutRouting; export type CreateOrganizationProps = RoutingOptions & { - /** - * Callback function triggered after successfully creating a new organization - */ - onComplete?: () => void; - /** * Full URL or path to navigate after creating a new organization. * @default undefined @@ -1402,11 +1399,6 @@ export type OrganizationSwitcherProps = CreateOrganizationMode & }; export type OrganizationListProps = { - /** - * Callback function triggered after successfully selecting a new organization - */ - onComplete?: () => void; - /** * Full URL or path to navigate after creating a new organization. * @default undefined @@ -1453,6 +1445,9 @@ export type OrganizationListProps = { * @default false */ hideSlug?: boolean; + + __internal_onOrganizationSelected?: () => void; + __internal_onOrganizationCreated?: () => void; }; export type WaitlistProps = { From 0e9a10c5fb202802cbdc9fdfdcccb5be05602192 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 18 Mar 2025 08:20:34 -0300 Subject: [PATCH 07/14] Fix test assertion --- integration/testUtils/index.ts | 2 +- integration/tests/session-tasks-sign-in.test.ts | 2 +- integration/tests/session-tasks-sign-up.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index b5a6c6fff0d..d04da00cc22 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -52,7 +52,7 @@ const createExpectPageObject = ({ page }: TestArgs) => { return !!window.Clerk?.user; }); }, - toHaveCurrentTask: async () => { + toHaveResolvedTask: async () => { return page.waitForFunction(() => { return !window.Clerk?.session?.currentTask; }); diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts index 666920898e3..d7a643c62bf 100644 --- a/integration/tests/session-tasks-sign-in.test.ts +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -40,7 +40,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( // Resolves task await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization); - await u.po.expect.toHaveCurrentTask(); + await u.po.expect.toHaveResolvedTask(); // Navigates to after sign-in await u.page.waitForAppUrl('/'); diff --git a/integration/tests/session-tasks-sign-up.test.ts b/integration/tests/session-tasks-sign-up.test.ts index 7034cf50475..b9752365115 100644 --- a/integration/tests/session-tasks-sign-up.test.ts +++ b/integration/tests/session-tasks-sign-up.test.ts @@ -41,7 +41,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( // Resolves task await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization); - await u.po.expect.toHaveCurrentTask(); + await u.po.expect.toHaveResolvedTask(); // Navigates to after sign-up await u.page.waitForAppUrl('/'); From f512dfcc2608ad641357b4fcb880fcaac157eaaf Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 18 Mar 2025 10:15:08 -0300 Subject: [PATCH 08/14] Introduce React context for session task methods --- .../components/OrganizationList/OrganizationListPage.tsx | 9 +++++---- .../components/OrganizationList/UserMembershipList.tsx | 9 ++++++--- .../clerk-js/src/ui/contexts/components/SessionTask.ts | 5 +++++ packages/clerk-js/src/ui/types.ts | 4 ++++ packages/types/src/clerk.ts | 3 --- 5 files changed, 20 insertions(+), 10 deletions(-) create mode 100644 packages/clerk-js/src/ui/contexts/components/SessionTask.ts diff --git a/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx b/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx index c33c11e0df2..d932090e524 100644 --- a/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx @@ -1,7 +1,8 @@ import { useOrganizationList, useUser } from '@clerk/shared/react'; -import { useState } from 'react'; +import { useContext, useState } from 'react'; import { useEnvironment, useOrganizationListContext } from '../../contexts'; +import { SessionTaskContext } from '../../contexts/components/SessionTask'; import { Box, Col, descriptors, Flex, localizationKeys, Spinner } from '../../customizables'; import { Action, Actions, Card, Header, useCardState, withCardStateProvider } from '../../elements'; import { useInView } from '../../hooks'; @@ -109,9 +110,9 @@ export const OrganizationListPage = withCardStateProvider(() => { }); const OrganizationListFlows = ({ showListInitially }: { showListInitially: boolean }) => { - const { navigateAfterCreateOrganization, skipInvitationScreen, hideSlug, __internal_onOrganizationCreated } = - useOrganizationListContext(); + const { navigateAfterCreateOrganization, skipInvitationScreen, hideSlug } = useOrganizationListContext(); const [isCreateOrganizationFlow, setCreateOrganizationFlow] = useState(!showListInitially); + const sessionTaskContext = useContext(SessionTaskContext); return ( <> {!isCreateOrganizationFlow && ( @@ -126,7 +127,7 @@ const OrganizationListFlows = ({ showListInitially }: { showListInitially: boole > diff --git a/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx b/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx index cded0ea87c1..fa4e36e73dc 100644 --- a/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx @@ -1,15 +1,18 @@ import { useOrganizationList, useUser } from '@clerk/shared/react'; import type { OrganizationResource } from '@clerk/types'; +import { useContext } from 'react'; import { useOrganizationListContext } from '../../contexts'; +import { SessionTaskContext } from '../../contexts/components/SessionTask'; import { OrganizationPreview, PersonalWorkspacePreview, useCardState, withCardStateProvider } from '../../elements'; import { localizationKeys } from '../../localization'; import { OrganizationListPreviewButton, sharedMainIdentifierSx } from './shared'; export const MembershipPreview = withCardStateProvider((props: { organization: OrganizationResource }) => { const card = useCardState(); - const { navigateAfterSelectOrganization, __internal_onOrganizationSelected } = useOrganizationListContext(); + const { navigateAfterSelectOrganization } = useOrganizationListContext(); const { isLoaded, setActive } = useOrganizationList(); + const sessionTaskContext = useContext(SessionTaskContext); if (!isLoaded) { return null; @@ -20,8 +23,8 @@ export const MembershipPreview = withCardStateProvider((props: { organization: O organization, }); - if (__internal_onOrganizationSelected) { - return __internal_onOrganizationSelected(); + if (sessionTaskContext?.nextTask) { + return sessionTaskContext?.nextTask(); } await navigateAfterSelectOrganization(organization); diff --git a/packages/clerk-js/src/ui/contexts/components/SessionTask.ts b/packages/clerk-js/src/ui/contexts/components/SessionTask.ts new file mode 100644 index 00000000000..c2b0afa4f2a --- /dev/null +++ b/packages/clerk-js/src/ui/contexts/components/SessionTask.ts @@ -0,0 +1,5 @@ +import { createContext } from 'react'; + +import type { SessionTaskCtx } from '../../types'; + +export const SessionTaskContext = createContext(null); diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index 70ca173b6e5..338b9d0ed8a 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -112,6 +112,10 @@ export type __experimental_CheckoutCtx = __experimental_CheckoutProps & { setIsOpen?: (open: boolean) => void; }; +export type SessionTaskCtx = { + nextTask: () => void; +}; + export type AvailableComponentCtx = | SignInCtx | SignUpCtx diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 8ce1c237b3a..7c450314680 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1445,9 +1445,6 @@ export type OrganizationListProps = { * @default false */ hideSlug?: boolean; - - __internal_onOrganizationSelected?: () => void; - __internal_onOrganizationCreated?: () => void; }; export type WaitlistProps = { From d9b5c2dae0974c6befb599adc35689ac60174068 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 18 Mar 2025 10:15:40 -0300 Subject: [PATCH 09/14] Rollback changes on isomorphic class --- packages/react/src/isomorphicClerk.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 07188c77600..d08d84a124a 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -4,7 +4,6 @@ import { handleValueOrFn } from '@clerk/shared/utils'; import type { __experimental_CommerceNamespace, __experimental_PricingTableProps, - __internal_ComponentNavigationContext, __internal_UserVerificationModalProps, __internal_UserVerificationProps, AuthenticateWithCoinbaseWalletParams, @@ -95,6 +94,7 @@ type IsomorphicLoadedClerk = Without< | '__internal_getCachedResources' | '__internal_reloadInitialResources' | '__experimental_commerce' + | '__internal_setComponentNavigationContext' > & { client: ClientResource | undefined; __experimental_commerce: __experimental_CommerceNamespace | undefined; @@ -621,14 +621,6 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; - __internal_setComponentNavigationContext = (params: __internal_ComponentNavigationContext) => { - if (this.clerkjs) { - return this.clerkjs.__internal_setComponentNavigationContext(params); - } else { - return undefined; - } - }; - /** * `setActive` can be used to set the active session and/or organization. */ From a25c190b4513bb707fd35495d0dc845800e9f06d Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 18 Mar 2025 11:20:06 -0300 Subject: [PATCH 10/14] Fix internal navigation context to support virtual routing --- packages/clerk-js/src/core/sessionTasks.ts | 2 +- .../clerk-js/src/ui/components/SignIn/SignIn.tsx | 6 +++--- .../clerk-js/src/ui/components/SignUp/SignUp.tsx | 6 +++--- packages/types/src/clerk.ts | 13 ++++++++++++- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/clerk-js/src/core/sessionTasks.ts b/packages/clerk-js/src/core/sessionTasks.ts index 074dbbcb657..aeef4fc5b85 100644 --- a/packages/clerk-js/src/core/sessionTasks.ts +++ b/packages/clerk-js/src/core/sessionTasks.ts @@ -30,7 +30,7 @@ export function navigateToTask( const taskRoute = `/${SESSION_TASK_ROUTE_BY_KEY[task.key]}`; if (componentNavigationContext) { - return componentNavigationContext.navigate(`/${componentNavigationContext.basePath + taskRoute}`); + return componentNavigationContext.navigate(componentNavigationContext.indexPath + taskRoute); } const signInUrl = options['signInUrl'] || environment.displayConfig.signInUrl; diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index 490d18c2ad7..08416ec915b 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -174,7 +174,7 @@ const usePreloadSessionTask = (enabled = false) => function SignInRoot() { const { __internal_setComponentNavigationContext } = useClerk(); - const { navigate, basePath } = useRouter(); + const { navigate, indexPath } = useRouter(); const signInContext = useSignInContext(); const normalizedSignUpContext = { @@ -197,8 +197,8 @@ function SignInRoot() { usePreloadSessionTask(signInContext.withSessionTasks); React.useEffect(() => { - return __internal_setComponentNavigationContext?.({ basePath, navigate }); - }, [basePath, navigate]); + return __internal_setComponentNavigationContext?.({ indexPath, navigate }); + }, [indexPath, navigate]); return ( diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx index d2c099bc909..b8833867273 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx @@ -28,15 +28,15 @@ function RedirectToSignUp() { function SignUpRoutes(): JSX.Element { const { __internal_setComponentNavigationContext } = useClerk(); - const { navigate, basePath } = useRouter(); + const { navigate, indexPath } = useRouter(); const signUpContext = useSignUpContext(); // `experimental.withSessionTasks` will be removed soon in favor of checking via environment response usePreloadSessionTask(signUpContext.withSessionTasks); React.useEffect(() => { - return __internal_setComponentNavigationContext?.({ basePath, navigate }); - }, [basePath, navigate]); + return __internal_setComponentNavigationContext?.({ indexPath, navigate }); + }, [indexPath, navigate]); return ( diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 7c450314680..6cb3eaf7046 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1077,13 +1077,24 @@ export type __internal_UserVerificationProps = RoutingOptions & { export type __internal_UserVerificationModalProps = WithoutRouting<__internal_UserVerificationProps>; export type __internal_ComponentNavigationContext = { + /** + * The `navigate` reference within the component router context + */ navigate: ( to: string, options?: { searchParams?: URLSearchParams; }, ) => Promise; - basePath: string; + /** + * This path represents the root route for a specific component type and is used + * for internal routing and navigation. + * + * @example + * indexPath: '/sign-in' // When + * indexPath: '/sign-up' // When + */ + indexPath: string; }; type GoogleOneTapRedirectUrlProps = SignInForceRedirectUrl & SignUpForceRedirectUrl; From 349a17f6bcb89f2e4eed99de282f5c7614c256e2 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 19 Mar 2025 07:03:28 -0300 Subject: [PATCH 11/14] Update max size for clerk.js bundle --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 3b2bf1010c2..cbe0ce70152 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "577.51kB" }, + { "path": "./dist/clerk.js", "maxSize": "580kB" }, { "path": "./dist/clerk.browser.js", "maxSize": "78.5kB" }, { "path": "./dist/clerk.headless.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "94KB" }, From f935c83d85c8601c467f6200df9adacb0317382c Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 19 Mar 2025 08:16:42 -0300 Subject: [PATCH 12/14] Delay emitting active session until navigating with `nextTask` --- .../clerk-js/src/core/__tests__/clerk.test.ts | 2 +- packages/clerk-js/src/core/clerk.ts | 48 +++++++++++++++---- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index c5e7fc51e8c..f0e373d4bed 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2301,8 +2301,8 @@ describe('Clerk singleton', () => { it('navigates to next task', async () => { const sut = new Clerk(productionPublishableKey); await sut.load(mockedLoadOptions); - await sut.setActive({ session: mockResource as any as PendingSessionResource }); + await sut.setActive({ session: mockResource as any as PendingSessionResource }); await sut.__experimental_nextTask(); expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/add-organization'); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index af35482c1c1..974e2e5568f 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -970,13 +970,12 @@ export class Clerk implements ClerkInterface { session = (this.client.sessions.find(x => x.id === session) as SignedInSessionResource) || null; } - if (session?.status === 'pending') { - await this.#handlePendingSession(session); + let newSession = session === undefined ? this.session : session; + if (newSession?.status === 'pending') { + await this.#handlePendingSession(newSession, organization); return; } - let newSession = session === undefined ? this.session : session; - // At this point, the `session` variable should contain either an `SignedInSessionResource` // ,`null` or `undefined`. // We now want to set the last active organization id on that session (if it exists). @@ -1064,15 +1063,35 @@ export class Clerk implements ClerkInterface { await onAfterSetActive(); }; - #handlePendingSession = async (session: PendingSessionResource) => { + #handlePendingSession = async ( + session: PendingSessionResource, + organization?: string | OrganizationResource | null | undefined, + ) => { if (!this.environment) { return; } + const shouldSwitchOrganization = organization !== undefined; + if (shouldSwitchOrganization) { + const organizationIdOrSlug = typeof organization === 'string' ? organization : organization?.id; + + if (isOrganizationId(organizationIdOrSlug)) { + session.lastActiveOrganizationId = organizationIdOrSlug || null; + } else { + const matchingOrganization = session.user.organizationMemberships.find( + mem => mem.organization.slug === organizationIdOrSlug, + ); + session.lastActiveOrganizationId = matchingOrganization?.organization.id || null; + } + } + + let newSession: SignedInSessionResource | null = session; + // Handles multi-session scenario when switching between `pending` sessions + // and satisfying task requirements such as organization selection if (inActiveBrowserTab() || !this.#options.standardBrowser) { await this.#touchCurrentSession(session); - session = (this.#getSessionFromClient(session.id) as PendingSessionResource) ?? session; + newSession = this.#getSessionFromClient(session.id) ?? session; } // Syncs __session and __client_uat, in case the `pending` session @@ -1082,16 +1101,20 @@ export class Clerk implements ClerkInterface { eventBus.dispatch(events.TokenUpdate, { token: null }); } - if (session.currentTask) { + if (newSession?.currentTask) { await navigateToTask(session.currentTask, { globalNavigate: this.navigate, componentNavigationContext: this.#componentNavigationContext, options: this.#options, environment: this.environment, }); + + // Delay updating session accessors until active status transition to prevent premature component unmounting. + // This is particularly important when SignIn components are wrapped in SignedOut components, + // as early state updates could cause unwanted unmounting during the transition. + this.#setAccessors(session); } - this.#setAccessors(session); this.#emit(); }; @@ -1111,8 +1134,12 @@ export class Clerk implements ClerkInterface { return; } + this.#setTransitiveState(); const defaultRedirectUrlComplete = this.client?.signUp ? this.buildAfterSignUpUrl() : this.buildAfterSignUpUrl(); await this.navigate(redirectUrlComplete ?? defaultRedirectUrlComplete); + + this.#setAccessors(session); + this.#emit(); }; public addListener = (listener: ListenerCallback): UnsubscribeCallback => { @@ -2275,6 +2302,11 @@ export class Clerk implements ClerkInterface { } }; + /** + * Temporarily clears the accessors before emitting changes to React context state. + * This is used during transitions like sign-out or session changes to prevent UI flickers + * such as unexpected unmount of control components + */ #setTransitiveState = () => { this.session = undefined; this.organization = undefined; From 65e6be45b3355b140a7a713bfdc682d6b59b7188 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Thu, 20 Mar 2025 09:33:17 -0300 Subject: [PATCH 13/14] Move organization selection up on `setActive` closure --- packages/clerk-js/src/core/clerk.ts | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 974e2e5568f..81e378a3128 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -971,10 +971,6 @@ export class Clerk implements ClerkInterface { } let newSession = session === undefined ? this.session : session; - if (newSession?.status === 'pending') { - await this.#handlePendingSession(newSession, organization); - return; - } // At this point, the `session` variable should contain either an `SignedInSessionResource` // ,`null` or `undefined`. @@ -996,6 +992,11 @@ export class Clerk implements ClerkInterface { } } + if (newSession?.status === 'pending') { + await this.#handlePendingSession(newSession); + return; + } + if (session?.lastActiveToken) { eventBus.dispatch(events.TokenUpdate, { token: session.lastActiveToken }); } @@ -1063,28 +1064,11 @@ export class Clerk implements ClerkInterface { await onAfterSetActive(); }; - #handlePendingSession = async ( - session: PendingSessionResource, - organization?: string | OrganizationResource | null | undefined, - ) => { + #handlePendingSession = async (session: PendingSessionResource) => { if (!this.environment) { return; } - const shouldSwitchOrganization = organization !== undefined; - if (shouldSwitchOrganization) { - const organizationIdOrSlug = typeof organization === 'string' ? organization : organization?.id; - - if (isOrganizationId(organizationIdOrSlug)) { - session.lastActiveOrganizationId = organizationIdOrSlug || null; - } else { - const matchingOrganization = session.user.organizationMemberships.find( - mem => mem.organization.slug === organizationIdOrSlug, - ); - session.lastActiveOrganizationId = matchingOrganization?.organization.id || null; - } - } - let newSession: SignedInSessionResource | null = session; // Handles multi-session scenario when switching between `pending` sessions From f8b7a5329906425e28f2aec5c202afad82a60f04 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Thu, 20 Mar 2025 09:58:02 -0300 Subject: [PATCH 14/14] Use beforeUnload tracker for navigating on task completion --- packages/clerk-js/src/core/clerk.ts | 13 +++++++++++-- packages/clerk-js/src/utils/beforeUnloadTracker.ts | 9 +++++++++ packages/types/src/clerk.ts | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 81e378a3128..db8bd7e64cb 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1118,9 +1118,18 @@ export class Clerk implements ClerkInterface { return; } - this.#setTransitiveState(); + const tracker = createBeforeUnloadTracker(this.#options.standardBrowser); const defaultRedirectUrlComplete = this.client?.signUp ? this.buildAfterSignUpUrl() : this.buildAfterSignUpUrl(); - await this.navigate(redirectUrlComplete ?? defaultRedirectUrlComplete); + + this.#setTransitiveState(); + + await tracker.track(async () => { + await this.navigate(redirectUrlComplete ?? defaultRedirectUrlComplete); + }); + + if (tracker.isUnloading()) { + return; + } this.#setAccessors(session); this.#emit(); diff --git a/packages/clerk-js/src/utils/beforeUnloadTracker.ts b/packages/clerk-js/src/utils/beforeUnloadTracker.ts index 5dd2cd793e8..59094c63bed 100644 --- a/packages/clerk-js/src/utils/beforeUnloadTracker.ts +++ b/packages/clerk-js/src/utils/beforeUnloadTracker.ts @@ -31,6 +31,15 @@ const createBeforeUnloadListener = () => { return { startListening, stopListening, isUnloading }; }; +/** + * Creates a beforeUnload event tracker to prevent state updates and re-renders during hard + * navigation events. + * + * It can be wrapped around navigation-related operations to ensure they don't trigger unnecessary + * state updates during page transitions. + * + * @internal + */ export const createBeforeUnloadTracker = (enabled = false) => { if (!enabled) { return { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 6cb3eaf7046..37323ed123b 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1605,7 +1605,7 @@ export interface AuthenticateWithGoogleOneTapParams { export interface NextTaskParams { /** - * Full URL or path to navigate after successful resolving all tasks + * Full URL or path to navigate after successfully resolving all tasks * @default undefined */ redirectUrlComplete?: string;