diff --git a/.changeset/tall-dolls-wish.md b/.changeset/tall-dolls-wish.md
new file mode 100644
index 00000000000..4d2519f357d
--- /dev/null
+++ b/.changeset/tall-dolls-wish.md
@@ -0,0 +1,5 @@
+---
+'@clerk/clerk-js': patch
+---
+
+Navigate to tasks when switching sessions
diff --git a/integration/templates/next-app-router/src/app/page.tsx b/integration/templates/next-app-router/src/app/page.tsx
index d6be346c4f9..5af7be97bf1 100644
--- a/integration/templates/next-app-router/src/app/page.tsx
+++ b/integration/templates/next-app-router/src/app/page.tsx
@@ -1,4 +1,4 @@
-import { SignedIn, SignedOut, SignIn, UserButton, Protect } from '@clerk/nextjs';
+import { Protect, SignedIn, SignedOut, SignIn, UserButton } from '@clerk/nextjs';
import Link from 'next/link';
import { ClientId } from './client-id';
@@ -11,7 +11,7 @@ export default function Home() {
SignedOut
SignedIn from protect
diff --git a/integration/tests/session-tasks-multi-session.test.ts b/integration/tests/session-tasks-multi-session.test.ts
new file mode 100644
index 00000000000..172c15819ca
--- /dev/null
+++ b/integration/tests/session-tasks-multi-session.test.ts
@@ -0,0 +1,96 @@
+import { test } from '@playwright/test';
+
+import { appConfigs } from '../presets';
+import type { FakeUser } from '../testUtils';
+import { createTestUtils, testAgainstRunningApps } from '../testUtils';
+
+testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
+ 'session tasks multi-session flow @nextjs',
+ ({ app }) => {
+ test.describe.configure({ mode: 'serial' });
+
+ let user1: FakeUser;
+ let user2: FakeUser;
+
+ test.beforeAll(async () => {
+ const u = createTestUtils({ app });
+
+ user1 = u.services.users.createFakeUser();
+ user2 = u.services.users.createFakeUser();
+
+ await u.services.users.createBapiUser(user1);
+ await u.services.users.createBapiUser(user2);
+ });
+
+ test.afterAll(async () => {
+ const u = createTestUtils({ app });
+ await user1.deleteIfExists();
+ await user2.deleteIfExists();
+ await u.services.organizations.deleteAll();
+ await app.teardown();
+ });
+
+ test.afterEach(async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.signOut();
+ await u.page.context().clearCookies();
+ });
+
+ test('when switching sessions, navigate to task', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ // Performs sign-in
+ await u.po.signIn.goTo();
+ await u.po.signIn.setIdentifier(user1.email);
+ await u.po.signIn.continue();
+ await u.po.signIn.setPassword(user1.password);
+ await u.po.signIn.continue();
+ await u.po.expect.toBeSignedIn();
+
+ // Resolves task
+ const fakeOrganization = Object.assign(u.services.organizations.createFakeOrganization(), {
+ slug: u.services.organizations.createFakeOrganization().slug + '-with-session-tasks',
+ });
+
+ await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization);
+ await u.po.expect.toHaveResolvedTask();
+
+ // Navigates to after sign-in
+ await u.page.waitForAppUrl('/');
+
+ // Create second user, to initiate a pending session
+ // Don't resolve task and switch to active session afterwards
+ await u.po.signIn.goTo();
+ await u.po.signIn.setIdentifier(user2.email);
+ await u.po.signIn.continue();
+ await u.po.signIn.setPassword(user2.password);
+ await u.po.signIn.continue();
+
+ // Sign-in again back with active session
+ await u.po.signIn.goTo();
+ await u.po.signIn.setIdentifier(user1.email);
+ await u.po.signIn.continue();
+ await u.po.signIn.setPassword(user1.password);
+ await u.po.signIn.continue();
+
+ // Navigate to protected page, with active session, where user button gets rendered
+ await u.page.goToRelative('/user-button');
+
+ // Switch account, to a session that has a pending status
+ await u.po.userButton.waitForMounted();
+ await u.po.userButton.toggleTrigger();
+ await u.po.userButton.waitForPopover();
+ await u.po.userButton.toHaveVisibleMenuItems([/Manage account/i, /Sign out$/i]);
+ await u.po.userButton.switchAccount(user2.email);
+
+ // Resolve task
+ await u.po.signIn.waitForMounted();
+ const fakeOrganization2 = u.services.organizations.createFakeOrganization();
+ await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization2);
+ await u.po.expect.toHaveResolvedTask();
+
+ // Navigates to after sign-in
+ await u.page.waitForAppUrl('/');
+ });
+ },
+);
diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts
index 0a383839ed2..7502fdee7be 100644
--- a/integration/tests/session-tasks-sign-in.test.ts
+++ b/integration/tests/session-tasks-sign-in.test.ts
@@ -1,5 +1,5 @@
import { createClerkClient } from '@clerk/backend';
-import { expect, test } from '@playwright/test';
+import { test } from '@playwright/test';
import { appConfigs } from '../presets';
import { instanceKeys } from '../presets/envs';
@@ -12,38 +12,46 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
({ app }) => {
test.describe.configure({ mode: 'serial' });
- let fakeUser: FakeUser;
+ let user: FakeUser;
test.beforeAll(async () => {
const u = createTestUtils({ app });
- fakeUser = u.services.users.createFakeUser();
- await u.services.users.createBapiUser(fakeUser);
+ user = u.services.users.createFakeUser();
+ await u.services.users.createBapiUser(user);
});
test.afterAll(async () => {
const u = createTestUtils({ app });
- await fakeUser.deleteIfExists();
+ await user.deleteIfExists();
await u.services.organizations.deleteAll();
await app.teardown();
});
- test.skip('with email and password, navigate to task on after sign-in', async ({ page, context }) => {
+ test.afterEach(async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.signOut();
+ await u.page.context().clearCookies();
+ });
+
+ test('with email and password, 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.setIdentifier(user.email);
await u.po.signIn.continue();
- await u.po.signIn.setPassword(fakeUser.password);
+ await u.po.signIn.setPassword(user.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
// Redirects back to tasks when accessing protected route by `auth.protect`
await u.page.goToRelative('/page-protected');
- expect(page.url()).toContain('tasks');
// Resolves task
- const fakeOrganization = u.services.organizations.createFakeOrganization();
+ const fakeOrganization = Object.assign(u.services.organizations.createFakeOrganization(), {
+ slug: u.services.organizations.createFakeOrganization().slug + '-with-sign-in-password',
+ });
+ await u.po.signIn.waitForMounted();
await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization);
await u.po.expect.toHaveResolvedTask();
@@ -51,7 +59,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
await u.page.waitForAppUrl('/');
});
- test.skip('with sso, navigate to task on after sign-in', async ({ page, context }) => {
+ test('with sso, navigate to task on after sign-in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
// Create a clerkClient for the OAuth provider instance
@@ -60,22 +68,25 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
publishableKey: instanceKeys.get('oauth-provider').pk,
});
const users = createUserService(client);
- fakeUser = users.createFakeUser({
+ const userFromOAuth = users.createFakeUser({
withUsername: true,
});
// Create the user on the OAuth provider instance so we do not need to sign up twice
- await users.createBapiUser(fakeUser);
+ await users.createBapiUser(userFromOAuth);
// Performs sign-in with SSO
await u.po.signIn.goTo();
await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click();
await u.page.getByText('Sign in to oauth-provider').waitFor();
- await u.po.signIn.setIdentifier(fakeUser.email);
+ await u.po.signIn.setIdentifier(userFromOAuth.email);
await u.po.signIn.continue();
await u.po.signIn.enterTestOtpCode();
// Resolves task
- const fakeOrganization = u.services.organizations.createFakeOrganization();
+ await u.po.signIn.waitForMounted();
+ const fakeOrganization = Object.assign(u.services.organizations.createFakeOrganization(), {
+ slug: u.services.organizations.createFakeOrganization().slug + '-with-sign-in-sso',
+ });
await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization);
await u.po.expect.toHaveResolvedTask();
@@ -83,9 +94,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
await u.page.waitForAppUrl('/');
// Delete the user on the OAuth provider instance
- await fakeUser.deleteIfExists();
+ await userFromOAuth.deleteIfExists();
// Delete the user on the app instance.
- await u.services.users.deleteIfExists({ email: fakeUser.email });
+ await u.services.users.deleteIfExists({ email: userFromOAuth.email });
});
},
);
diff --git a/integration/tests/session-tasks-sign-up.test.ts b/integration/tests/session-tasks-sign-up.test.ts
index d10e5e3ee85..aad5e5fd3c2 100644
--- a/integration/tests/session-tasks-sign-up.test.ts
+++ b/integration/tests/session-tasks-sign-up.test.ts
@@ -1,11 +1,8 @@
import { expect, test } from '@playwright/test';
-import { createClerkClient } from '@clerk/backend';
import { appConfigs } from '../presets';
-import { instanceKeys } from '../presets/envs';
import type { FakeUser } from '../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
-import { createUserService } from '../testUtils/usersService';
testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
'session tasks after sign-up flow @nextjs',
@@ -30,7 +27,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
await app.teardown();
});
- test.skip('navigate to task on after sign-up', async ({ page, context }) => {
+ test('navigate to task on after sign-up', async ({ page, context }) => {
// Performs sign-up
const u = createTestUtils({ app, page, context });
await u.po.signUp.goTo();
@@ -42,52 +39,17 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
// Redirects back to tasks when accessing protected route by `auth.protect`
await u.page.goToRelative('/page-protected');
- expect(page.url()).toContain('tasks');
+ expect(u.page.url()).toContain('tasks');
// Resolves task
- const fakeOrganization = u.services.organizations.createFakeOrganization();
- await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization);
- await u.po.expect.toHaveResolvedTask();
-
- // Navigates to after sign-up
- await u.page.waitForAppUrl('/');
- });
-
- test.skip('with sso, navigate to task on after sign-up', async ({ page, context }) => {
- const u = createTestUtils({ app, page, context });
-
- // Create a clerkClient for the OAuth provider instance
- const client = createClerkClient({
- secretKey: instanceKeys.get('oauth-provider').sk,
- publishableKey: instanceKeys.get('oauth-provider').pk,
- });
- const users = createUserService(client);
- fakeUser = users.createFakeUser({
- withUsername: true,
+ const fakeOrganization = Object.assign(u.services.organizations.createFakeOrganization(), {
+ slug: u.services.organizations.createFakeOrganization().slug + '-with-sign-up',
});
- // Create the user on the OAuth provider instance so we do not need to sign up twice
- await users.createBapiUser(fakeUser);
-
- // Performs sign-up (transfer flow with sign-in) with SSO
- await u.po.signIn.goTo();
- await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click();
- await u.page.getByText('Sign in to oauth-provider').waitFor();
- await u.po.signIn.setIdentifier(fakeUser.email);
- await u.po.signIn.continue();
- await u.po.signIn.enterTestOtpCode();
-
- // Resolves task
- const fakeOrganization = u.services.organizations.createFakeOrganization();
await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization);
await u.po.expect.toHaveResolvedTask();
// Navigates to after sign-up
await u.page.waitForAppUrl('/');
-
- // Delete the user on the OAuth provider instance
- await fakeUser.deleteIfExists();
- // Delete the user on the app instance.
- await u.services.users.deleteIfExists({ email: fakeUser.email });
});
},
);
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index 6ef84ec3abc..53895dc7e9b 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": "612.37kB" },
+ { "path": "./dist/clerk.js", "maxSize": "614kB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "72.2KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "55KB" },
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index 80a87e2a838..2a216b7b6d0 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -1209,16 +1209,17 @@ export class Clerk implements ClerkInterface {
}
}
- if (newSession?.status === 'pending') {
- await this.#handlePendingSession(newSession);
- return;
- }
-
/**
* Hint to each framework, that the user will be signed out when `{session: null}` is provided.
*/
await onBeforeSetActive(newSession === null ? 'sign-out' : undefined);
+ if (newSession?.status === 'pending') {
+ await this.#handlePendingSession(newSession);
+ await onAfterSetActive();
+ return;
+ }
+
//1. setLastActiveSession to passed user session (add a param).
// Note that this will also update the session's active organization
// id.
@@ -1301,8 +1302,10 @@ export class Clerk implements ClerkInterface {
eventBus.emit(events.TokenUpdate, { token: null });
}
- // Only triggers navigation for internal routing, in order to not affect custom flows
- if (newSession?.currentTask && this.#componentNavigationContext) {
+ // Only triggers navigation for internal AIO components routing or multi-session switch
+ const isSwitchingSessions = this.session?.id != session.id;
+ const shouldNavigateOnSetActive = this.#componentNavigationContext || isSwitchingSessions;
+ if (newSession?.currentTask && shouldNavigateOnSetActive) {
await navigateToTask(session.currentTask.key, {
options: this.#options,
environment: this.environment,
@@ -1317,7 +1320,7 @@ export class Clerk implements ClerkInterface {
public __experimental_navigateToTask = async ({ redirectUrlComplete }: NextTaskParams = {}): Promise => {
/**
- * Invalidate previously cache pages with auth state before navigating
+ * Invalidate previously cached pages with auth state before navigating
*/
const onBeforeSetActive: SetActiveHook =
typeof window !== 'undefined' && typeof window.__unstable__onBeforeSetActive === 'function'
diff --git a/packages/testing/src/playwright/unstable/page-objects/userButton.ts b/packages/testing/src/playwright/unstable/page-objects/userButton.ts
index 044f8de5d27..69804cc4ef3 100644
--- a/packages/testing/src/playwright/unstable/page-objects/userButton.ts
+++ b/packages/testing/src/playwright/unstable/page-objects/userButton.ts
@@ -32,6 +32,9 @@ export const createUserButtonPageObject = (testArgs: { page: EnhancedPage }) =>
triggerManageAccount: () => {
return page.getByRole('menuitem', { name: /Manage account/i }).click();
},
+ switchAccount: (emailAddress: string) => {
+ return page.getByText(emailAddress).click();
+ },
};
return self;