diff --git a/.changeset/purple-balloons-join.md b/.changeset/purple-balloons-join.md new file mode 100644 index 00000000000..7cec7f23a69 --- /dev/null +++ b/.changeset/purple-balloons-join.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': patch +'@clerk/clerk-react': patch +'@clerk/types': patch +--- + +Introduce `__experimental_nextTask` method for navigating to next tasks on a after-auth flow diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index 7523027cd72..d04da00cc22 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; }); }, + toHaveResolvedTask: 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..cf1f7f29001 --- /dev/null +++ b/integration/testUtils/organizationsService.ts @@ -0,0 +1,25 @@ +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..d7a643c62bf 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.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 ad5003eb2f3..b9752365115 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.toHaveResolvedTask(); - await fakeUser.deleteIfExists(); + // Navigates to after sign-up + await u.page.waitForAppUrl('/'); }); }, ); diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 978f4b8009a..cbe0ce70152 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,8 +1,8 @@ { "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": "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 ea573685bd0..f0e373d4bed 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; @@ -2258,4 +2268,91 @@ describe('Clerk singleton', () => { }); }); }); + + describe('nextTask', () => { + 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 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' }, + }), + ), + }; + + afterEach(() => { + mockSession.remove.mockReset(); + mockSession.touch.mockReset(); + (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 }); + + const redirectUrlComplete = '/welcome-to-app'; + await sut.__experimental_nextTask({ redirectUrlComplete }); + + console.log(mockNavigate.mock.calls); + + 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 4960f3f2fc4..db8bd7e64cb 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, @@ -40,10 +41,12 @@ import type { JoinWaitlistParams, ListenerCallback, NavigateOptions, + NextTaskParams, OrganizationListProps, OrganizationProfileProps, OrganizationResource, OrganizationSwitcherProps, + PendingSessionResource, PublicKeyCredentialCreationOptionsWithoutExtensions, PublicKeyCredentialRequestOptionsWithoutExtensions, PublicKeyCredentialWithAuthenticatorAssertionResponse, @@ -201,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 }>) @@ -975,11 +970,6 @@ 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); - return; - } - let newSession = session === undefined ? this.session : session; // At this point, the `session` variable should contain either an `SignedInSessionResource` @@ -1002,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 }); } @@ -1069,16 +1064,18 @@ 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` + 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) ?? session; + newSession = this.#getSessionFromClient(session.id) ?? session; } // Syncs __session and __client_uat, in case the `pending` session @@ -1088,13 +1085,50 @@ 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.#emit(); + }; + + public __experimental_nextTask = async ({ redirectUrlComplete }: NextTaskParams = {}): Promise => { + const session = await this.session?.reload(); + if (!session || !this.environment) { + return; + } + + if (session.status === 'pending') { + await navigateToTask(session.currentTask, { + options: this.#options, + environment: this.environment, + globalNavigate: this.navigate, + componentNavigationContext: this.#componentNavigationContext, + }); + return; + } + + const tracker = createBeforeUnloadTracker(this.#options.standardBrowser); + const defaultRedirectUrlComplete = this.client?.signUp ? this.buildAfterSignUpUrl() : this.buildAfterSignUpUrl(); + + this.#setTransitiveState(); + + await tracker.track(async () => { + await this.navigate(redirectUrlComplete ?? defaultRedirectUrlComplete); + }); + + if (tracker.isUnloading()) { + return; } this.#setAccessors(session); @@ -1128,15 +1162,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); @@ -2269,6 +2295,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; diff --git a/packages/clerk-js/src/core/sessionTasks.ts b/packages/clerk-js/src/core/sessionTasks.ts index 29d7d9e0481..aeef4fc5b85 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; @@ -33,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/OrganizationList/OrganizationListPage.tsx b/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx index 8e6ec776c26..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'; @@ -111,6 +112,7 @@ export const OrganizationListPage = withCardStateProvider(() => { const OrganizationListFlows = ({ showListInitially }: { showListInitially: boolean }) => { const { navigateAfterCreateOrganization, skipInvitationScreen, hideSlug } = useOrganizationListContext(); const [isCreateOrganizationFlow, setCreateOrganizationFlow] = useState(!showListInitially); + const sessionTaskContext = useContext(SessionTaskContext); return ( <> {!isCreateOrganizationFlow && ( @@ -125,6 +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 5d5ed4149c8..fa4e36e73dc 100644 --- a/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx @@ -1,7 +1,9 @@ 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'; @@ -10,6 +12,7 @@ export const MembershipPreview = withCardStateProvider((props: { organization: O const card = useCardState(); const { navigateAfterSelectOrganization } = useOrganizationListContext(); const { isLoaded, setActive } = useOrganizationList(); + const sessionTaskContext = useContext(SessionTaskContext); if (!isLoaded) { return null; @@ -19,6 +22,11 @@ export const MembershipPreview = withCardStateProvider((props: { organization: O await setActive({ organization, }); + + if (sessionTaskContext?.nextTask) { + return sessionTaskContext?.nextTask(); + } + await navigateAfterSelectOrganization(organization); }); }; 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..08416ec915b 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 && ( - + )} @@ -168,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 = { @@ -191,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 9e42f7efac8..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 ( @@ -91,7 +91,10 @@ function SignUpRoutes(): JSX.Element { {signUpContext.withSessionTasks && ( - + )} 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/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/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index c2b85093152..d08d84a124a 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -23,6 +23,7 @@ import type { JoinWaitlistParams, ListenerCallback, LoadedClerk, + NextTaskParams, OrganizationListProps, OrganizationProfileProps, OrganizationResource, @@ -612,6 +613,14 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + __experimental_nextTask = async (params: NextTaskParams): Promise => { + if (this.clerkjs) { + return this.clerkjs.__experimental_nextTask(params); + } else { + return Promise.reject(); + } + }; + /** * `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 2127800578f..37323ed123b 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. @@ -646,6 +638,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. @@ -1076,6 +1076,27 @@ 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; + /** + * 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; export type GoogleOneTapProps = GoogleOneTapRedirectUrlProps & { @@ -1582,6 +1603,14 @@ export interface AuthenticateWithGoogleOneTapParams { legalAccepted?: boolean; } +export interface NextTaskParams { + /** + * Full URL or path to navigate after successfully resolving all tasks + * @default undefined + */ + redirectUrlComplete?: string; +} + export interface LoadedClerk extends Clerk { client: ClientResource; }