Skip to content

Commit 3910ebe

Browse files
authoredMar 21, 2025··
feat(clerk-js,clerk-react,types): Navigate to next task (#5377)
1 parent 598d1ce commit 3910ebe

20 files changed

+398
-77
lines changed
 

‎.changeset/purple-balloons-join.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/clerk-react': patch
4+
'@clerk/types': patch
5+
---
6+
7+
Introduce `__experimental_nextTask` method for navigating to next tasks on a after-auth flow

‎integration/testUtils/index.ts

+9
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import { createAppPageObject } from './appPageObject';
77
import { createEmailService } from './emailService';
88
import { createInvitationService } from './invitationsService';
99
import { createKeylessPopoverPageObject } from './keylessPopoverPageObject';
10+
import { createOrganizationsService } from './organizationsService';
1011
import { createOrganizationSwitcherComponentPageObject } from './organizationSwitcherPageObject';
12+
import { createSessionTaskComponentPageObject } from './sessionTaskPageObject';
1113
import type { EnchancedPage, TestArgs } from './signInPageObject';
1214
import { createSignInComponentPageObject } from './signInPageObject';
1315
import { createSignUpComponentPageObject } from './signUpPageObject';
@@ -50,6 +52,11 @@ const createExpectPageObject = ({ page }: TestArgs) => {
5052
return !!window.Clerk?.user;
5153
});
5254
},
55+
toHaveResolvedTask: async () => {
56+
return page.waitForFunction(() => {
57+
return !window.Clerk?.session?.currentTask;
58+
});
59+
},
5360
};
5461
};
5562

@@ -87,6 +94,7 @@ export const createTestUtils = <
8794
email: createEmailService(),
8895
users: createUserService(clerkClient),
8996
invitations: createInvitationService(clerkClient),
97+
organizations: createOrganizationsService(clerkClient),
9098
clerk: clerkClient,
9199
};
92100

@@ -106,6 +114,7 @@ export const createTestUtils = <
106114
userButton: createUserButtonPageObject(testArgs),
107115
userVerification: createUserVerificationComponentPageObject(testArgs),
108116
waitlist: createWaitlistComponentPageObject(testArgs),
117+
sessionTask: createSessionTaskComponentPageObject(testArgs),
109118
expect: createExpectPageObject(testArgs),
110119
clerk: createClerkUtils(testArgs),
111120
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { ClerkClient, Organization } from '@clerk/backend';
2+
import { faker } from '@faker-js/faker';
3+
4+
export type FakeOrganization = Pick<Organization, 'slug' | 'name'>;
5+
6+
export type OrganizationService = {
7+
deleteAll: () => Promise<void>;
8+
createFakeOrganization: () => FakeOrganization;
9+
};
10+
11+
export const createOrganizationsService = (clerkClient: ClerkClient) => {
12+
const self: OrganizationService = {
13+
createFakeOrganization: () => ({
14+
slug: faker.helpers.slugify(faker.commerce.department()).toLowerCase(),
15+
name: faker.commerce.department(),
16+
}),
17+
deleteAll: async () => {
18+
const organizations = await clerkClient.organizations.getOrganizationList();
19+
const bulkDeletionPromises = organizations.data.map(({ id }) => clerkClient.organizations.deleteOrganization(id));
20+
await Promise.all(bulkDeletionPromises);
21+
},
22+
};
23+
24+
return self;
25+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { common } from './commonPageObject';
4+
import type { FakeOrganization } from './organizationsService';
5+
import type { TestArgs } from './signInPageObject';
6+
7+
export const createSessionTaskComponentPageObject = (testArgs: TestArgs) => {
8+
const { page } = testArgs;
9+
10+
const self = {
11+
...common(testArgs),
12+
resolveForceOrganizationSelectionTask: async (fakeOrganization: FakeOrganization) => {
13+
const createOrganizationButton = page.getByRole('button', { name: /create organization/i });
14+
15+
await expect(createOrganizationButton).toBeVisible();
16+
expect(page.url()).toContain('add-organization');
17+
18+
await page.locator('input[name=name]').fill(fakeOrganization.name);
19+
await page.locator('input[name=slug]').fill(fakeOrganization.slug);
20+
21+
await createOrganizationButton.click();
22+
},
23+
};
24+
25+
return self;
26+
};
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,49 @@
1-
import { expect, test } from '@playwright/test';
1+
import { test } from '@playwright/test';
22

33
import { appConfigs } from '../presets';
44
import type { FakeUser } from '../testUtils';
55
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
6+
import type { FakeOrganization } from '../testUtils/organizationsService';
67

78
testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
89
'session tasks after sign-in flow @nextjs',
910
({ app }) => {
1011
test.describe.configure({ mode: 'serial' });
1112

1213
let fakeUser: FakeUser;
14+
let fakeOrganization: FakeOrganization;
1315

1416
test.beforeAll(async () => {
1517
const u = createTestUtils({ app });
1618
fakeUser = u.services.users.createFakeUser();
19+
fakeOrganization = u.services.organizations.createFakeOrganization();
1720
await u.services.users.createBapiUser(fakeUser);
1821
});
1922

2023
test.afterAll(async () => {
24+
const u = createTestUtils({ app });
2125
await fakeUser.deleteIfExists();
26+
await u.services.organizations.deleteAll();
2227
await app.teardown();
2328
});
2429

2530
test('navigate to task on after sign-in', async ({ page, context }) => {
2631
const u = createTestUtils({ app, page, context });
32+
33+
// Performs sign-in
2734
await u.po.signIn.goTo();
2835
await u.po.signIn.setIdentifier(fakeUser.email);
2936
await u.po.signIn.continue();
3037
await u.po.signIn.setPassword(fakeUser.password);
3138
await u.po.signIn.continue();
3239
await u.po.expect.toBeSignedIn();
3340

34-
await expect(u.page.getByRole('button', { name: /create organization/i })).toBeVisible();
35-
expect(page.url()).toContain('add-organization');
41+
// Resolves task
42+
await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization);
43+
await u.po.expect.toHaveResolvedTask();
44+
45+
// Navigates to after sign-in
46+
await u.page.waitForAppUrl('/');
3647
});
3748
},
3849
);
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,50 @@
1-
import { expect, test } from '@playwright/test';
1+
import { test } from '@playwright/test';
22

33
import { appConfigs } from '../presets';
4+
import type { FakeUser } from '../testUtils';
45
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
6+
import type { FakeOrganization } from '../testUtils/organizationsService';
57

68
testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
79
'session tasks after sign-up flow @nextjs',
810
({ app }) => {
911
test.describe.configure({ mode: 'serial' });
1012

13+
let fakeUser: FakeUser;
14+
let fakeOrganization: FakeOrganization;
15+
16+
test.beforeAll(() => {
17+
const u = createTestUtils({ app });
18+
fakeUser = u.services.users.createFakeUser({
19+
fictionalEmail: true,
20+
withPhoneNumber: true,
21+
withUsername: true,
22+
});
23+
fakeOrganization = u.services.organizations.createFakeOrganization();
24+
});
25+
1126
test.afterAll(async () => {
27+
const u = createTestUtils({ app });
28+
await u.services.organizations.deleteAll();
29+
await fakeUser.deleteIfExists();
1230
await app.teardown();
1331
});
1432

1533
test('navigate to task on after sign-up', async ({ page, context }) => {
34+
// Performs sign-up
1635
const u = createTestUtils({ app, page, context });
17-
const fakeUser = u.services.users.createFakeUser({
18-
fictionalEmail: true,
19-
withPhoneNumber: true,
20-
withUsername: true,
21-
});
2236
await u.po.signUp.goTo();
2337
await u.po.signUp.signUpWithEmailAndPassword({
2438
email: fakeUser.email,
2539
password: fakeUser.password,
2640
});
2741

28-
await expect(u.page.getByRole('button', { name: /create organization/i })).toBeVisible();
29-
expect(page.url()).toContain('add-organization');
42+
// Resolves task
43+
await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization);
44+
await u.po.expect.toHaveResolvedTask();
3045

31-
await fakeUser.deleteIfExists();
46+
// Navigates to after sign-up
47+
await u.page.waitForAppUrl('/');
3248
});
3349
},
3450
);

‎packages/clerk-js/bundlewatch.config.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"files": [
3-
{ "path": "./dist/clerk.js", "maxSize": "577.51kB" },
3+
{ "path": "./dist/clerk.js", "maxSize": "580kB" },
44
{ "path": "./dist/clerk.browser.js", "maxSize": "78.5kB" },
5-
{ "path": "./dist/clerk.headless.js", "maxSize": "51KB" },
5+
{ "path": "./dist/clerk.headless.js", "maxSize": "55KB" },
66
{ "path": "./dist/ui-common*.js", "maxSize": "94KB" },
77
{ "path": "./dist/vendors*.js", "maxSize": "30KB" },
88
{ "path": "./dist/coinbase*.js", "maxSize": "35.5KB" },

‎packages/clerk-js/src/core/__tests__/clerk.test.ts

+97
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
ActiveSessionResource,
3+
PendingSessionResource,
34
SignedInSessionResource,
45
SignInJSON,
56
SignUpJSON,
@@ -486,6 +487,15 @@ describe('Clerk singleton', () => {
486487
lastActiveToken: { getRawString: () => 'mocked-token' },
487488
tasks: [{ key: 'org' }],
488489
currentTask: { key: 'org', __internal_getUrl: () => 'https://foocorp.com/add-organization' },
490+
reload: jest.fn(() =>
491+
Promise.resolve({
492+
id: '1',
493+
status: 'pending',
494+
user: {},
495+
tasks: [{ key: 'org' }],
496+
currentTask: { key: 'org', __internal_getUrl: () => 'https://foocorp.com/add-organization' },
497+
}),
498+
),
489499
};
490500
let eventBusSpy;
491501

@@ -2258,4 +2268,91 @@ describe('Clerk singleton', () => {
22582268
});
22592269
});
22602270
});
2271+
2272+
describe('nextTask', () => {
2273+
describe('with `pending` session status', () => {
2274+
const mockSession = {
2275+
id: '1',
2276+
status: 'pending',
2277+
user: {},
2278+
tasks: [{ key: 'org' }],
2279+
currentTask: { key: 'org', __internal_getUrl: () => 'https://foocorp.com/add-organization' },
2280+
lastActiveToken: { getRawString: () => 'mocked-token' },
2281+
};
2282+
2283+
const mockResource = {
2284+
...mockSession,
2285+
remove: jest.fn(),
2286+
touch: jest.fn(() => Promise.resolve()),
2287+
getToken: jest.fn(),
2288+
reload: jest.fn(() => Promise.resolve(mockSession)),
2289+
};
2290+
2291+
beforeAll(() => {
2292+
mockResource.touch.mockReturnValueOnce(Promise.resolve());
2293+
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockResource] }));
2294+
});
2295+
2296+
afterEach(() => {
2297+
mockResource.remove.mockReset();
2298+
mockResource.touch.mockReset();
2299+
});
2300+
2301+
it('navigates to next task', async () => {
2302+
const sut = new Clerk(productionPublishableKey);
2303+
await sut.load(mockedLoadOptions);
2304+
2305+
await sut.setActive({ session: mockResource as any as PendingSessionResource });
2306+
await sut.__experimental_nextTask();
2307+
2308+
expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/add-organization');
2309+
});
2310+
});
2311+
2312+
describe('with `active` session status', () => {
2313+
const mockSession = {
2314+
id: '1',
2315+
remove: jest.fn(),
2316+
status: 'active',
2317+
user: {},
2318+
touch: jest.fn(() => Promise.resolve()),
2319+
getToken: jest.fn(),
2320+
lastActiveToken: { getRawString: () => 'mocked-token' },
2321+
reload: jest.fn(() =>
2322+
Promise.resolve({
2323+
id: '1',
2324+
remove: jest.fn(),
2325+
status: 'active',
2326+
user: {},
2327+
touch: jest.fn(() => Promise.resolve()),
2328+
getToken: jest.fn(),
2329+
lastActiveToken: { getRawString: () => 'mocked-token' },
2330+
}),
2331+
),
2332+
};
2333+
2334+
afterEach(() => {
2335+
mockSession.remove.mockReset();
2336+
mockSession.touch.mockReset();
2337+
(window as any).__unstable__onBeforeSetActive = null;
2338+
(window as any).__unstable__onAfterSetActive = null;
2339+
});
2340+
2341+
it('navigates to redirect url on completion', async () => {
2342+
mockSession.touch.mockReturnValue(Promise.resolve());
2343+
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] }));
2344+
2345+
const sut = new Clerk(productionPublishableKey);
2346+
await sut.load(mockedLoadOptions);
2347+
await sut.setActive({ session: mockSession as any as ActiveSessionResource });
2348+
2349+
const redirectUrlComplete = '/welcome-to-app';
2350+
await sut.__experimental_nextTask({ redirectUrlComplete });
2351+
2352+
console.log(mockNavigate.mock.calls);
2353+
2354+
expect(mockNavigate.mock.calls[0][0]).toBe('/welcome-to-app');
2355+
});
2356+
});
2357+
});
22612358
});

‎packages/clerk-js/src/core/clerk.ts

+59-28
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { handleValueOrFn, noop } from '@clerk/shared/utils';
1515
import type {
1616
__experimental_CommerceNamespace,
1717
__experimental_PricingTableProps,
18+
__internal_ComponentNavigationContext,
1819
__internal_UserVerificationModalProps,
1920
AuthenticateWithCoinbaseWalletParams,
2021
AuthenticateWithGoogleOneTapParams,
@@ -40,10 +41,12 @@ import type {
4041
JoinWaitlistParams,
4142
ListenerCallback,
4243
NavigateOptions,
44+
NextTaskParams,
4345
OrganizationListProps,
4446
OrganizationProfileProps,
4547
OrganizationResource,
4648
OrganizationSwitcherProps,
49+
PendingSessionResource,
4750
PublicKeyCredentialCreationOptionsWithoutExtensions,
4851
PublicKeyCredentialRequestOptionsWithoutExtensions,
4952
PublicKeyCredentialWithAuthenticatorAssertionResponse,
@@ -201,15 +204,7 @@ export class Clerk implements ClerkInterface {
201204
#options: ClerkOptions = {};
202205
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
203206
#touchThrottledUntil = 0;
204-
#componentNavigationContext: {
205-
navigate: (
206-
to: string,
207-
options?: {
208-
searchParams?: URLSearchParams;
209-
},
210-
) => Promise<unknown>;
211-
basePath: string;
212-
} | null = null;
207+
#componentNavigationContext: __internal_ComponentNavigationContext | null = null;
213208

214209
public __internal_getCachedResources:
215210
| (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>)
@@ -975,11 +970,6 @@ export class Clerk implements ClerkInterface {
975970
session = (this.client.sessions.find(x => x.id === session) as SignedInSessionResource) || null;
976971
}
977972

978-
if (session?.status === 'pending') {
979-
await this.#handlePendingSession(session);
980-
return;
981-
}
982-
983973
let newSession = session === undefined ? this.session : session;
984974

985975
// At this point, the `session` variable should contain either an `SignedInSessionResource`
@@ -1002,6 +992,11 @@ export class Clerk implements ClerkInterface {
1002992
}
1003993
}
1004994

995+
if (newSession?.status === 'pending') {
996+
await this.#handlePendingSession(newSession);
997+
return;
998+
}
999+
10051000
if (session?.lastActiveToken) {
10061001
eventBus.dispatch(events.TokenUpdate, { token: session.lastActiveToken });
10071002
}
@@ -1069,16 +1064,18 @@ export class Clerk implements ClerkInterface {
10691064
await onAfterSetActive();
10701065
};
10711066

1072-
#handlePendingSession = async (session: SignedInSessionResource) => {
1067+
#handlePendingSession = async (session: PendingSessionResource) => {
10731068
if (!this.environment) {
10741069
return;
10751070
}
10761071

1077-
// Handles multi-session scenario when switching from `active`
1078-
// to `pending`
1072+
let newSession: SignedInSessionResource | null = session;
1073+
1074+
// Handles multi-session scenario when switching between `pending` sessions
1075+
// and satisfying task requirements such as organization selection
10791076
if (inActiveBrowserTab() || !this.#options.standardBrowser) {
10801077
await this.#touchCurrentSession(session);
1081-
session = this.#getSessionFromClient(session.id) ?? session;
1078+
newSession = this.#getSessionFromClient(session.id) ?? session;
10821079
}
10831080

10841081
// Syncs __session and __client_uat, in case the `pending` session
@@ -1088,13 +1085,50 @@ export class Clerk implements ClerkInterface {
10881085
eventBus.dispatch(events.TokenUpdate, { token: null });
10891086
}
10901087

1091-
if (session.currentTask) {
1088+
if (newSession?.currentTask) {
10921089
await navigateToTask(session.currentTask, {
10931090
globalNavigate: this.navigate,
10941091
componentNavigationContext: this.#componentNavigationContext,
10951092
options: this.#options,
10961093
environment: this.environment,
10971094
});
1095+
1096+
// Delay updating session accessors until active status transition to prevent premature component unmounting.
1097+
// This is particularly important when SignIn components are wrapped in SignedOut components,
1098+
// as early state updates could cause unwanted unmounting during the transition.
1099+
this.#setAccessors(session);
1100+
}
1101+
1102+
this.#emit();
1103+
};
1104+
1105+
public __experimental_nextTask = async ({ redirectUrlComplete }: NextTaskParams = {}): Promise<void> => {
1106+
const session = await this.session?.reload();
1107+
if (!session || !this.environment) {
1108+
return;
1109+
}
1110+
1111+
if (session.status === 'pending') {
1112+
await navigateToTask(session.currentTask, {
1113+
options: this.#options,
1114+
environment: this.environment,
1115+
globalNavigate: this.navigate,
1116+
componentNavigationContext: this.#componentNavigationContext,
1117+
});
1118+
return;
1119+
}
1120+
1121+
const tracker = createBeforeUnloadTracker(this.#options.standardBrowser);
1122+
const defaultRedirectUrlComplete = this.client?.signUp ? this.buildAfterSignUpUrl() : this.buildAfterSignUpUrl();
1123+
1124+
this.#setTransitiveState();
1125+
1126+
await tracker.track(async () => {
1127+
await this.navigate(redirectUrlComplete ?? defaultRedirectUrlComplete);
1128+
});
1129+
1130+
if (tracker.isUnloading()) {
1131+
return;
10981132
}
10991133

11001134
this.#setAccessors(session);
@@ -1128,15 +1162,7 @@ export class Clerk implements ClerkInterface {
11281162
return unsubscribe;
11291163
};
11301164

1131-
public __internal_setComponentNavigationContext = (context: {
1132-
navigate: (
1133-
to: string,
1134-
options?: {
1135-
searchParams?: URLSearchParams;
1136-
},
1137-
) => Promise<unknown>;
1138-
basePath: string;
1139-
}) => {
1165+
public __internal_setComponentNavigationContext = (context: __internal_ComponentNavigationContext) => {
11401166
this.#componentNavigationContext = context;
11411167

11421168
return () => (this.#componentNavigationContext = null);
@@ -2269,6 +2295,11 @@ export class Clerk implements ClerkInterface {
22692295
}
22702296
};
22712297

2298+
/**
2299+
* Temporarily clears the accessors before emitting changes to React context state.
2300+
* This is used during transitions like sign-out or session changes to prevent UI flickers
2301+
* such as unexpected unmount of control components
2302+
*/
22722303
#setTransitiveState = () => {
22732304
this.session = undefined;
22742305
this.organization = undefined;

‎packages/clerk-js/src/core/sessionTasks.ts

+8-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { ClerkOptions, EnvironmentResource, SessionTask } from '@clerk/types';
1+
import type {
2+
__internal_ComponentNavigationContext,
3+
ClerkOptions,
4+
EnvironmentResource,
5+
SessionTask,
6+
} from '@clerk/types';
27

38
import { buildURL } from '../utils';
49

@@ -7,15 +12,7 @@ export const SESSION_TASK_ROUTE_BY_KEY: Record<SessionTask['key'], string> = {
712
} as const;
813

914
interface NavigateToTaskOptions {
10-
componentNavigationContext: {
11-
navigate: (
12-
to: string,
13-
options?: {
14-
searchParams?: URLSearchParams;
15-
},
16-
) => Promise<unknown>;
17-
basePath: string;
18-
} | null;
15+
componentNavigationContext: __internal_ComponentNavigationContext | null;
1916
globalNavigate: (to: string) => Promise<unknown>;
2017
options: ClerkOptions;
2118
environment: EnvironmentResource;
@@ -33,7 +30,7 @@ export function navigateToTask(
3330
const taskRoute = `/${SESSION_TASK_ROUTE_BY_KEY[task.key]}`;
3431

3532
if (componentNavigationContext) {
36-
return componentNavigationContext.navigate(`/${componentNavigationContext.basePath + taskRoute}`);
33+
return componentNavigationContext.navigate(componentNavigationContext.indexPath + taskRoute);
3734
}
3835

3936
const signInUrl = options['signInUrl'] || environment.displayConfig.signInUrl;

‎packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { useOrganizationList, useUser } from '@clerk/shared/react';
2-
import { useState } from 'react';
2+
import { useContext, useState } from 'react';
33

44
import { useEnvironment, useOrganizationListContext } from '../../contexts';
5+
import { SessionTaskContext } from '../../contexts/components/SessionTask';
56
import { Box, Col, descriptors, Flex, localizationKeys, Spinner } from '../../customizables';
67
import { Action, Actions, Card, Header, useCardState, withCardStateProvider } from '../../elements';
78
import { useInView } from '../../hooks';
@@ -111,6 +112,7 @@ export const OrganizationListPage = withCardStateProvider(() => {
111112
const OrganizationListFlows = ({ showListInitially }: { showListInitially: boolean }) => {
112113
const { navigateAfterCreateOrganization, skipInvitationScreen, hideSlug } = useOrganizationListContext();
113114
const [isCreateOrganizationFlow, setCreateOrganizationFlow] = useState(!showListInitially);
115+
const sessionTaskContext = useContext(SessionTaskContext);
114116
return (
115117
<>
116118
{!isCreateOrganizationFlow && (
@@ -125,6 +127,7 @@ const OrganizationListFlows = ({ showListInitially }: { showListInitially: boole
125127
>
126128
<CreateOrganizationForm
127129
flow='organizationList'
130+
onComplete={sessionTaskContext?.nextTask}
128131
startPage={{ headerTitle: localizationKeys('organizationList.createOrganization') }}
129132
skipInvitationScreen={skipInvitationScreen}
130133
navigateAfterCreateOrganization={org =>

‎packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { useOrganizationList, useUser } from '@clerk/shared/react';
22
import type { OrganizationResource } from '@clerk/types';
3+
import { useContext } from 'react';
34

45
import { useOrganizationListContext } from '../../contexts';
6+
import { SessionTaskContext } from '../../contexts/components/SessionTask';
57
import { OrganizationPreview, PersonalWorkspacePreview, useCardState, withCardStateProvider } from '../../elements';
68
import { localizationKeys } from '../../localization';
79
import { OrganizationListPreviewButton, sharedMainIdentifierSx } from './shared';
@@ -10,6 +12,7 @@ export const MembershipPreview = withCardStateProvider((props: { organization: O
1012
const card = useCardState();
1113
const { navigateAfterSelectOrganization } = useOrganizationListContext();
1214
const { isLoaded, setActive } = useOrganizationList();
15+
const sessionTaskContext = useContext(SessionTaskContext);
1316

1417
if (!isLoaded) {
1518
return null;
@@ -19,6 +22,11 @@ export const MembershipPreview = withCardStateProvider((props: { organization: O
1922
await setActive({
2023
organization,
2124
});
25+
26+
if (sessionTaskContext?.nextTask) {
27+
return sessionTaskContext?.nextTask();
28+
}
29+
2230
await navigateAfterSelectOrganization(organization);
2331
});
2432
};

‎packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx

+31-5
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
import { useClerk } from '@clerk/shared/react/index';
1+
import { useClerk } from '@clerk/shared/react';
22
import { eventComponentMounted } from '@clerk/shared/telemetry';
33
import type { SessionTask } from '@clerk/types';
4+
import { useCallback, useEffect } from 'react';
45

56
import { OrganizationListContext } from '../../contexts';
7+
import { SessionTaskContext as SessionTaskContext } from '../../contexts/components/SessionTask';
8+
import { useRouter } from '../../router';
69
import { OrganizationList } from '../OrganizationList';
710

11+
interface SessionTaskProps {
12+
task: SessionTask['key'];
13+
redirectUrlComplete: string;
14+
}
15+
816
const ContentRegistry: Record<SessionTask['key'], React.ComponentType> = {
917
org: () => (
1018
<OrganizationListContext.Provider
@@ -21,12 +29,30 @@ const ContentRegistry: Record<SessionTask['key'], React.ComponentType> = {
2129
/**
2230
* @internal
2331
*/
24-
export function SessionTask({ task }: { task: SessionTask['key'] }): React.ReactNode {
25-
const clerk = useClerk();
32+
export function SessionTask({ task, redirectUrlComplete }: SessionTaskProps): React.ReactNode {
33+
const { session, telemetry, __experimental_nextTask } = useClerk();
34+
const { navigate } = useRouter();
35+
36+
useEffect(() => {
37+
if (session?.currentTask) {
38+
return;
39+
}
40+
41+
void navigate(redirectUrlComplete);
42+
}, [session?.currentTask, navigate, redirectUrlComplete]);
43+
44+
telemetry?.record(eventComponentMounted('SessionTask', { task }));
2645

27-
clerk.telemetry?.record(eventComponentMounted('SessionTask', { task }));
46+
const nextTask = useCallback(
47+
() => __experimental_nextTask({ redirectUrlComplete }),
48+
[__experimental_nextTask, redirectUrlComplete],
49+
);
2850

2951
const Content = ContentRegistry[task];
3052

31-
return <Content />;
53+
return (
54+
<SessionTaskContext.Provider value={{ nextTask }}>
55+
<Content />
56+
</SessionTaskContext.Provider>
57+
);
3258
}

‎packages/clerk-js/src/ui/components/SignIn/SignIn.tsx

+11-5
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,10 @@ function SignInRoutes(): JSX.Element {
132132
</Route>
133133
{signInContext.withSessionTasks && (
134134
<Route path={SESSION_TASK_ROUTE_BY_KEY['org']}>
135-
<SessionTask task='org' />
135+
<SessionTask
136+
task='org'
137+
redirectUrlComplete={signInContext.afterSignUpUrl}
138+
/>
136139
</Route>
137140
)}
138141
<Route index>
@@ -146,7 +149,10 @@ function SignInRoutes(): JSX.Element {
146149
)}
147150
{signInContext.withSessionTasks && (
148151
<Route path={SESSION_TASK_ROUTE_BY_KEY['org']}>
149-
<SessionTask task='org' />
152+
<SessionTask
153+
task='org'
154+
redirectUrlComplete={signInContext.afterSignInUrl}
155+
/>
150156
</Route>
151157
)}
152158
<Route index>
@@ -168,7 +174,7 @@ const usePreloadSessionTask = (enabled = false) =>
168174

169175
function SignInRoot() {
170176
const { __internal_setComponentNavigationContext } = useClerk();
171-
const { navigate, basePath } = useRouter();
177+
const { navigate, indexPath } = useRouter();
172178

173179
const signInContext = useSignInContext();
174180
const normalizedSignUpContext = {
@@ -191,8 +197,8 @@ function SignInRoot() {
191197
usePreloadSessionTask(signInContext.withSessionTasks);
192198

193199
React.useEffect(() => {
194-
return __internal_setComponentNavigationContext?.({ basePath, navigate });
195-
}, [basePath, navigate]);
200+
return __internal_setComponentNavigationContext?.({ indexPath, navigate });
201+
}, [indexPath, navigate]);
196202

197203
return (
198204
<SignUpContext.Provider value={normalizedSignUpContext}>

‎packages/clerk-js/src/ui/components/SignUp/SignUp.tsx

+7-4
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,15 @@ function RedirectToSignUp() {
2828

2929
function SignUpRoutes(): JSX.Element {
3030
const { __internal_setComponentNavigationContext } = useClerk();
31-
const { navigate, basePath } = useRouter();
31+
const { navigate, indexPath } = useRouter();
3232
const signUpContext = useSignUpContext();
3333

3434
// `experimental.withSessionTasks` will be removed soon in favor of checking via environment response
3535
usePreloadSessionTask(signUpContext.withSessionTasks);
3636

3737
React.useEffect(() => {
38-
return __internal_setComponentNavigationContext?.({ basePath, navigate });
39-
}, [basePath, navigate]);
38+
return __internal_setComponentNavigationContext?.({ indexPath, navigate });
39+
}, [indexPath, navigate]);
4040

4141
return (
4242
<Flow.Root flow='signUp'>
@@ -91,7 +91,10 @@ function SignUpRoutes(): JSX.Element {
9191
</Route>
9292
{signUpContext.withSessionTasks && (
9393
<Route path={SESSION_TASK_ROUTE_BY_KEY['org']}>
94-
<SessionTask task='org' />
94+
<SessionTask
95+
task='org'
96+
redirectUrlComplete={signUpContext.afterSignUpUrl}
97+
/>
9598
</Route>
9699
)}
97100
<Route index>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { createContext } from 'react';
2+
3+
import type { SessionTaskCtx } from '../../types';
4+
5+
export const SessionTaskContext = createContext<SessionTaskCtx | null>(null);

‎packages/clerk-js/src/ui/types.ts

+4
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ export type __experimental_CheckoutCtx = __experimental_CheckoutProps & {
112112
setIsOpen?: (open: boolean) => void;
113113
};
114114

115+
export type SessionTaskCtx = {
116+
nextTask: () => void;
117+
};
118+
115119
export type AvailableComponentCtx =
116120
| SignInCtx
117121
| SignUpCtx

‎packages/clerk-js/src/utils/beforeUnloadTracker.ts

+9
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ const createBeforeUnloadListener = () => {
3131
return { startListening, stopListening, isUnloading };
3232
};
3333

34+
/**
35+
* Creates a beforeUnload event tracker to prevent state updates and re-renders during hard
36+
* navigation events.
37+
*
38+
* It can be wrapped around navigation-related operations to ensure they don't trigger unnecessary
39+
* state updates during page transitions.
40+
*
41+
* @internal
42+
*/
3443
export const createBeforeUnloadTracker = (enabled = false) => {
3544
if (!enabled) {
3645
return {

‎packages/react/src/isomorphicClerk.ts

+9
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
JoinWaitlistParams,
2424
ListenerCallback,
2525
LoadedClerk,
26+
NextTaskParams,
2627
OrganizationListProps,
2728
OrganizationProfileProps,
2829
OrganizationResource,
@@ -612,6 +613,14 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
612613
}
613614
};
614615

616+
__experimental_nextTask = async (params: NextTaskParams): Promise<void> => {
617+
if (this.clerkjs) {
618+
return this.clerkjs.__experimental_nextTask(params);
619+
} else {
620+
return Promise.reject();
621+
}
622+
};
623+
615624
/**
616625
* `setActive` can be used to set the active session and/or organization.
617626
*/

‎packages/types/src/clerk.ts

+38-9
Original file line numberDiff line numberDiff line change
@@ -434,15 +434,7 @@ export interface Clerk {
434434
* be triggered from `Clerk` methods
435435
* @internal
436436
*/
437-
__internal_setComponentNavigationContext: (context: {
438-
navigate: (
439-
to: string,
440-
options?: {
441-
searchParams?: URLSearchParams;
442-
},
443-
) => Promise<unknown>;
444-
basePath: string;
445-
}) => () => void;
437+
__internal_setComponentNavigationContext: (context: __internal_ComponentNavigationContext) => () => void;
446438

447439
/**
448440
* Set the active session and organization explicitly.
@@ -646,6 +638,14 @@ export interface Clerk {
646638

647639
joinWaitlist: (params: JoinWaitlistParams) => Promise<WaitlistResource>;
648640

641+
/**
642+
* Navigates to the next task or redirects to completion URL.
643+
* If the current session has pending tasks, it navigates to the next task.
644+
* If all tasks are complete, it navigates to the provided completion URL.
645+
* @experimental
646+
*/
647+
__experimental_nextTask: (params: NextTaskParams) => Promise<void>;
648+
649649
/**
650650
* This is an optional function.
651651
* 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 & {
10761076

10771077
export type __internal_UserVerificationModalProps = WithoutRouting<__internal_UserVerificationProps>;
10781078

1079+
export type __internal_ComponentNavigationContext = {
1080+
/**
1081+
* The `navigate` reference within the component router context
1082+
*/
1083+
navigate: (
1084+
to: string,
1085+
options?: {
1086+
searchParams?: URLSearchParams;
1087+
},
1088+
) => Promise<unknown>;
1089+
/**
1090+
* This path represents the root route for a specific component type and is used
1091+
* for internal routing and navigation.
1092+
*
1093+
* @example
1094+
* indexPath: '/sign-in' // When <SignIn path='/sign-in' />
1095+
* indexPath: '/sign-up' // When <SignUp path='/sign-up' />
1096+
*/
1097+
indexPath: string;
1098+
};
1099+
10791100
type GoogleOneTapRedirectUrlProps = SignInForceRedirectUrl & SignUpForceRedirectUrl;
10801101

10811102
export type GoogleOneTapProps = GoogleOneTapRedirectUrlProps & {
@@ -1582,6 +1603,14 @@ export interface AuthenticateWithGoogleOneTapParams {
15821603
legalAccepted?: boolean;
15831604
}
15841605

1606+
export interface NextTaskParams {
1607+
/**
1608+
* Full URL or path to navigate after successfully resolving all tasks
1609+
* @default undefined
1610+
*/
1611+
redirectUrlComplete?: string;
1612+
}
1613+
15851614
export interface LoadedClerk extends Clerk {
15861615
client: ClientResource;
15871616
}

0 commit comments

Comments
 (0)
Please sign in to comment.