From 81b60091cee608311fd2c68357f11461f6a15453 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 9 Jul 2025 11:13:42 -0300 Subject: [PATCH 01/13] Navigate to tasks when switching sessions with `setActive` --- packages/clerk-js/src/core/clerk.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 80a87e2a838..21493bc1b59 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -28,9 +28,9 @@ import type { AuthenticateWithGoogleOneTapParams, AuthenticateWithMetamaskParams, AuthenticateWithOKXWalletParams, - Clerk as ClerkInterface, ClerkAPIError, ClerkAuthenticateWithWeb3Params, + Clerk as ClerkInterface, ClerkOptions, ClientJSONSnapshot, ClientResource, @@ -1301,8 +1301,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, in order to not affect custom flows + 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, From 8a71b722eed9d4b79c841d7412e58a9a6557a4be Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 9 Jul 2025 11:49:06 -0300 Subject: [PATCH 02/13] Revalidate server state --- packages/clerk-js/src/core/clerk.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 21493bc1b59..0a75694a314 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -28,9 +28,9 @@ import type { AuthenticateWithGoogleOneTapParams, AuthenticateWithMetamaskParams, AuthenticateWithOKXWalletParams, + Clerk as ClerkInterface, ClerkAPIError, ClerkAuthenticateWithWeb3Params, - Clerk as ClerkInterface, ClerkOptions, ClientJSONSnapshot, ClientResource, @@ -1301,7 +1301,16 @@ export class Clerk implements ClerkInterface { eventBus.emit(events.TokenUpdate, { token: null }); } - // Only triggers navigation for internal AIO components routing or multi-session switch, in order to not affect custom flows + /** + * Invalidate previously cache pages with auth state before navigating + */ + const onBeforeSetActive: SetActiveHook = + typeof window !== 'undefined' && typeof window.__unstable__onBeforeSetActive === 'function' + ? window.__unstable__onBeforeSetActive + : noop; + await onBeforeSetActive(); + + // 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) { @@ -1315,6 +1324,17 @@ export class Clerk implements ClerkInterface { this.#setAccessors(session); this.#emit(); + + /** + * Invoke the Next.js middleware to synchronize server and client state after resolving a session task. + * This ensures that any server-side logic depending on the session status (like middleware-based + * redirects or protected routes) correctly reflects the updated client authentication state. + */ + const onAfterSetActive: SetActiveHook = + typeof window !== 'undefined' && typeof window.__unstable__onAfterSetActive === 'function' + ? window.__unstable__onAfterSetActive + : noop; + await onAfterSetActive(); }; public __experimental_navigateToTask = async ({ redirectUrlComplete }: NextTaskParams = {}): Promise => { From c8e724b61fb389acb2f41b40ba99389abd01f7ff Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 9 Jul 2025 11:52:05 -0300 Subject: [PATCH 03/13] Add changeset --- .changeset/tall-dolls-wish.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tall-dolls-wish.md 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 From c4280098d41bab9140ea10a506f23abbee7bcc61 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:46:09 -0300 Subject: [PATCH 04/13] Create user on every test block instead of on hook --- .../tests/session-tasks-sign-in.test.ts | 38 +++++++++++++++---- .../tests/session-tasks-sign-up.test.ts | 16 +++----- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts index 0a383839ed2..66629302115 100644 --- a/integration/tests/session-tasks-sign-in.test.ts +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -13,13 +13,6 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( test.describe.configure({ mode: 'serial' }); let fakeUser: FakeUser; - - test.beforeAll(async () => { - const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser(); - await u.services.users.createBapiUser(fakeUser); - }); - test.afterAll(async () => { const u = createTestUtils({ app }); await fakeUser.deleteIfExists(); @@ -29,6 +22,8 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( test.skip('with email and password, navigate to task on after sign-in', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); // Performs sign-in await u.po.signIn.goTo(); @@ -87,5 +82,34 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( // Delete the user on the app instance. await u.services.users.deleteIfExists({ email: fakeUser.email }); }); + + test('when switching sessions, navigate to task', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + + // Create user for second session + const fakeUser2 = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser2); + + // Performs sign-in + await u.po.signIn.goTo(); + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + await u.po.expect.toBeSignedIn(); + + // Resolves task + const fakeOrganization = u.services.organizations.createFakeOrganization(); + await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization); + await u.po.expect.toHaveResolvedTask(); + + // Navigates to after sign-in + await u.page.waitForAppUrl('/'); + + await u.po.userButton.toggleTrigger(); + await u.po.userButton.waitForPopover(); + }); }, ); diff --git a/integration/tests/session-tasks-sign-up.test.ts b/integration/tests/session-tasks-sign-up.test.ts index d10e5e3ee85..7682d063df4 100644 --- a/integration/tests/session-tasks-sign-up.test.ts +++ b/integration/tests/session-tasks-sign-up.test.ts @@ -13,16 +13,6 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( test.describe.configure({ mode: 'serial' }); let fakeUser: FakeUser; - - test.beforeAll(() => { - const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser({ - fictionalEmail: true, - withPhoneNumber: true, - withUsername: true, - }); - }); - test.afterAll(async () => { const u = createTestUtils({ app }); await u.services.organizations.deleteAll(); @@ -33,6 +23,12 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( test.skip('navigate to task on after sign-up', async ({ page, context }) => { // Performs sign-up const u = createTestUtils({ app, page, context }); + fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPhoneNumber: true, + withUsername: true, + }); + await u.po.signUp.goTo(); await u.po.signUp.signUpWithEmailAndPassword({ email: fakeUser.email, From c2902557f9d1bdda601dac5bdf55163a6d95977d Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 9 Jul 2025 17:23:04 -0300 Subject: [PATCH 05/13] Remove duplicated test block between sign-in --- .../tests/session-tasks-sign-in.test.ts | 31 +--------- .../tests/session-tasks-sign-up.test.ts | 57 ++++--------------- packages/clerk-js/src/core/clerk.ts | 18 +++--- 3 files changed, 21 insertions(+), 85 deletions(-) diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts index 66629302115..cf51d1ea0d7 100644 --- a/integration/tests/session-tasks-sign-in.test.ts +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -1,6 +1,6 @@ -import { createClerkClient } from '@clerk/backend'; 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'; @@ -82,34 +82,5 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( // Delete the user on the app instance. await u.services.users.deleteIfExists({ email: fakeUser.email }); }); - - test('when switching sessions, navigate to task', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - fakeUser = u.services.users.createFakeUser(); - await u.services.users.createBapiUser(fakeUser); - - // Create user for second session - const fakeUser2 = u.services.users.createFakeUser(); - await u.services.users.createBapiUser(fakeUser2); - - // Performs sign-in - await u.po.signIn.goTo(); - await u.po.signIn.setIdentifier(fakeUser.email); - await u.po.signIn.continue(); - await u.po.signIn.setPassword(fakeUser.password); - await u.po.signIn.continue(); - await u.po.expect.toBeSignedIn(); - - // Resolves task - const fakeOrganization = u.services.organizations.createFakeOrganization(); - await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization); - await u.po.expect.toHaveResolvedTask(); - - // Navigates to after sign-in - await u.page.waitForAppUrl('/'); - - await u.po.userButton.toggleTrigger(); - await u.po.userButton.waitForPopover(); - }); }, ); diff --git a/integration/tests/session-tasks-sign-up.test.ts b/integration/tests/session-tasks-sign-up.test.ts index 7682d063df4..574917bb0e9 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', @@ -13,6 +10,16 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( test.describe.configure({ mode: 'serial' }); let fakeUser: FakeUser; + + test.beforeAll(() => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPhoneNumber: true, + withUsername: true, + }); + }); + test.afterAll(async () => { const u = createTestUtils({ app }); await u.services.organizations.deleteAll(); @@ -20,14 +27,9 @@ 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 }); - fakeUser = u.services.users.createFakeUser({ - fictionalEmail: true, - withPhoneNumber: true, - withUsername: true, - }); await u.po.signUp.goTo(); await u.po.signUp.signUpWithEmailAndPassword({ @@ -48,42 +50,5 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( // 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, - }); - // 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/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 0a75694a314..1359c004bea 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1285,6 +1285,15 @@ export class Clerk implements ClerkInterface { return; } + /** + * Invalidate previously cache pages with auth state before navigating + */ + const onBeforeSetActive: SetActiveHook = + typeof window !== 'undefined' && typeof window.__unstable__onBeforeSetActive === 'function' + ? window.__unstable__onBeforeSetActive + : noop; + await onBeforeSetActive(); + let newSession: SignedInSessionResource | null = session; // Handles multi-session scenario when switching between `pending` sessions @@ -1301,15 +1310,6 @@ export class Clerk implements ClerkInterface { eventBus.emit(events.TokenUpdate, { token: null }); } - /** - * Invalidate previously cache pages with auth state before navigating - */ - const onBeforeSetActive: SetActiveHook = - typeof window !== 'undefined' && typeof window.__unstable__onBeforeSetActive === 'function' - ? window.__unstable__onBeforeSetActive - : noop; - await onBeforeSetActive(); - // Only triggers navigation for internal AIO components routing or multi-session switch const isSwitchingSessions = this.session?.id != session.id; const shouldNavigateOnSetActive = this.#componentNavigationContext || isSwitchingSessions; From 1f929d6ca190c977620b475f7bec6ea1d0e19654 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 9 Jul 2025 18:24:39 -0300 Subject: [PATCH 06/13] Add E2E test for switching sessions with task --- .../tests/session-tasks-sign-in.test.ts | 98 ++++++++++++++++--- .../tests/session-tasks-sign-up.test.ts | 3 +- packages/clerk-js/src/core/clerk.ts | 22 +---- .../unstable/page-objects/userButton.ts | 6 ++ 4 files changed, 91 insertions(+), 38 deletions(-) diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts index cf51d1ea0d7..8ec04697803 100644 --- a/integration/tests/session-tasks-sign-in.test.ts +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -1,6 +1,6 @@ -import { expect, test } from '@playwright/test'; - import { createClerkClient } from '@clerk/backend'; +import { test } from '@playwright/test'; + import { appConfigs } from '../presets'; import { instanceKeys } from '../presets/envs'; import type { FakeUser } from '../testUtils'; @@ -12,32 +12,99 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( ({ app }) => { test.describe.configure({ mode: 'serial' }); - let fakeUser: FakeUser; + 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 fakeUser.deleteIfExists(); + await user1.deleteIfExists(); + await user2.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('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 = u.services.organizations.createFakeOrganization(); + 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.switchAccount(user2.firstName); + + // Resolve task + await u.po.signIn.waitForMounted(); + const fakeOrganization2 = u.services.organizations.createFakeOrganization(); + await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization2); + await u.po.expect.toHaveResolvedTask(); + }); + + test('with email and password, navigate to task on after sign-in', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); - fakeUser = u.services.users.createFakeUser(); - await u.services.users.createBapiUser(fakeUser); // Performs sign-in await u.po.signIn.goTo(); - await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.setIdentifier(user1.email); await u.po.signIn.continue(); - await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.setPassword(user1.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 + await u.po.signIn.waitForMounted(); const fakeOrganization = u.services.organizations.createFakeOrganization(); await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization); await u.po.expect.toHaveResolvedTask(); @@ -55,21 +122,22 @@ 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 + await u.po.signIn.waitForMounted(); const fakeOrganization = u.services.organizations.createFakeOrganization(); await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization); await u.po.expect.toHaveResolvedTask(); @@ -78,9 +146,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 574917bb0e9..8f3be4dbeeb 100644 --- a/integration/tests/session-tasks-sign-up.test.ts +++ b/integration/tests/session-tasks-sign-up.test.ts @@ -30,7 +30,6 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( 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(); await u.po.signUp.signUpWithEmailAndPassword({ email: fakeUser.email, @@ -40,7 +39,7 @@ 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(); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 1359c004bea..79c0634b0a9 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1285,15 +1285,6 @@ export class Clerk implements ClerkInterface { return; } - /** - * Invalidate previously cache pages with auth state before navigating - */ - const onBeforeSetActive: SetActiveHook = - typeof window !== 'undefined' && typeof window.__unstable__onBeforeSetActive === 'function' - ? window.__unstable__onBeforeSetActive - : noop; - await onBeforeSetActive(); - let newSession: SignedInSessionResource | null = session; // Handles multi-session scenario when switching between `pending` sessions @@ -1324,22 +1315,11 @@ export class Clerk implements ClerkInterface { this.#setAccessors(session); this.#emit(); - - /** - * Invoke the Next.js middleware to synchronize server and client state after resolving a session task. - * This ensures that any server-side logic depending on the session status (like middleware-based - * redirects or protected routes) correctly reflects the updated client authentication state. - */ - const onAfterSetActive: SetActiveHook = - typeof window !== 'undefined' && typeof window.__unstable__onAfterSetActive === 'function' - ? window.__unstable__onAfterSetActive - : noop; - await onAfterSetActive(); }; 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..fadfb6b81c3 100644 --- a/packages/testing/src/playwright/unstable/page-objects/userButton.ts +++ b/packages/testing/src/playwright/unstable/page-objects/userButton.ts @@ -32,6 +32,12 @@ export const createUserButtonPageObject = (testArgs: { page: EnhancedPage }) => triggerManageAccount: () => { return page.getByRole('menuitem', { name: /Manage account/i }).click(); }, + switchAccount: (username: string) => { + return page + .getByRole('menuitem') + .filter({ has: page.locator('span', { hasText: username }) }) + .click(); + }, }; return self; From 46cf927efe0e9070cbf9afa2315c9780927cbca6 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:17:31 -0300 Subject: [PATCH 07/13] Introduce separate suite for multi-session --- .../tests/session-tasks-multi-session.test.ts | 92 +++++++++++++++++++ .../tests/session-tasks-sign-in.test.ts | 75 ++------------- 2 files changed, 101 insertions(+), 66 deletions(-) create mode 100644 integration/tests/session-tasks-multi-session.test.ts 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..1501da6a53e --- /dev/null +++ b/integration/tests/session-tasks-multi-session.test.ts @@ -0,0 +1,92 @@ +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 = u.services.organizations.createFakeOrganization(); + 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.switchAccount(user2.firstName); + + // Resolve task + await u.page.waitForAppUrl('/sign-in/tasks/add-organization'); + 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 8ec04697803..50349958a10 100644 --- a/integration/tests/session-tasks-sign-in.test.ts +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -1,10 +1,10 @@ -import { createClerkClient } from '@clerk/backend'; import { test } from '@playwright/test'; import { appConfigs } from '../presets'; -import { instanceKeys } from '../presets/envs'; import type { FakeUser } from '../testUtils'; import { createTestUtils, testAgainstRunningApps } from '../testUtils'; +import { createClerkClient } from '@clerk/backend'; +import { instanceKeys } from '../presets/envs'; import { createUserService } from '../testUtils/usersService'; testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( @@ -12,23 +12,17 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( ({ app }) => { test.describe.configure({ mode: 'serial' }); - let user1: FakeUser; - let user2: FakeUser; + let user: 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); + user = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(user); }); test.afterAll(async () => { const u = createTestUtils({ app }); - await user1.deleteIfExists(); - await user2.deleteIfExists(); + await user.deleteIfExists(); await u.services.organizations.deleteAll(); await app.teardown(); }); @@ -39,64 +33,14 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( 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 = u.services.organizations.createFakeOrganization(); - 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.switchAccount(user2.firstName); - - // Resolve task - await u.po.signIn.waitForMounted(); - const fakeOrganization2 = u.services.organizations.createFakeOrganization(); - await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization2); - await u.po.expect.toHaveResolvedTask(); - }); - 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(user1.email); + await u.po.signIn.setIdentifier(user.email); await u.po.signIn.continue(); - await u.po.signIn.setPassword(user1.password); + await u.po.signIn.setPassword(user.password); await u.po.signIn.continue(); await u.po.expect.toBeSignedIn(); @@ -104,7 +48,6 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( await u.page.goToRelative('/page-protected'); // Resolves task - await u.po.signIn.waitForMounted(); const fakeOrganization = u.services.organizations.createFakeOrganization(); await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization); await u.po.expect.toHaveResolvedTask(); @@ -137,7 +80,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( await u.po.signIn.enterTestOtpCode(); // Resolves task - await u.po.signIn.waitForMounted(); + await u.page.waitForAppUrl('/sign-in/tasks/add-organization'); const fakeOrganization = u.services.organizations.createFakeOrganization(); await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization); await u.po.expect.toHaveResolvedTask(); From fe58e399ffb4e426ecca50214f65026b28087806 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Fri, 11 Jul 2025 13:54:54 -0300 Subject: [PATCH 08/13] Add method to await for task to be mounted --- integration/tests/session-tasks-multi-session.test.ts | 5 ++++- integration/tests/session-tasks-sign-in.test.ts | 9 +++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/integration/tests/session-tasks-multi-session.test.ts b/integration/tests/session-tasks-multi-session.test.ts index 1501da6a53e..2b222073265 100644 --- a/integration/tests/session-tasks-multi-session.test.ts +++ b/integration/tests/session-tasks-multi-session.test.ts @@ -48,7 +48,10 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( await u.po.expect.toBeSignedIn(); // Resolves task - const fakeOrganization = u.services.organizations.createFakeOrganization(); + 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(); diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts index 50349958a10..c34782d3cbd 100644 --- a/integration/tests/session-tasks-sign-in.test.ts +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -1,10 +1,10 @@ +import { createClerkClient } from '@clerk/backend'; import { test } from '@playwright/test'; import { appConfigs } from '../presets'; +import { instanceKeys } from '../presets/envs'; import type { FakeUser } from '../testUtils'; import { createTestUtils, testAgainstRunningApps } from '../testUtils'; -import { createClerkClient } from '@clerk/backend'; -import { instanceKeys } from '../presets/envs'; import { createUserService } from '../testUtils/usersService'; testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( @@ -49,6 +49,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( // Resolves task const fakeOrganization = u.services.organizations.createFakeOrganization(); + await u.po.signIn.waitForMounted(); await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization); await u.po.expect.toHaveResolvedTask(); @@ -56,7 +57,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 @@ -80,7 +81,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( await u.po.signIn.enterTestOtpCode(); // Resolves task - await u.page.waitForAppUrl('/sign-in/tasks/add-organization'); + await u.po.signIn.waitForMounted(); const fakeOrganization = u.services.organizations.createFakeOrganization(); await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization); await u.po.expect.toHaveResolvedTask(); From 3adbec70023ae3bb957fbfca3f7916455e194b5d Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Fri, 11 Jul 2025 15:42:47 -0300 Subject: [PATCH 09/13] Revalidate server state on `setActive` with pending session --- .../templates/next-app-router/src/app/page.tsx | 2 +- packages/clerk-js/src/core/clerk.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/integration/templates/next-app-router/src/app/page.tsx b/integration/templates/next-app-router/src/app/page.tsx index d6be346c4f9..f3a415f17ce 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'; diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 79c0634b0a9..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. From 46bf92f02769f753348d5fd224ebd0a44fdfd010 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Fri, 11 Jul 2025 15:55:11 -0300 Subject: [PATCH 10/13] Fix `SignIn` to use hash routing --- integration/templates/next-app-router/src/app/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/templates/next-app-router/src/app/page.tsx b/integration/templates/next-app-router/src/app/page.tsx index f3a415f17ce..5af7be97bf1 100644 --- a/integration/templates/next-app-router/src/app/page.tsx +++ b/integration/templates/next-app-router/src/app/page.tsx @@ -11,7 +11,7 @@ export default function Home() { SignedOut SignedIn from protect
    From 7221d8f2e2267bdfe0e73836e7e1eaf0193a0aa9 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Fri, 11 Jul 2025 16:25:24 -0300 Subject: [PATCH 11/13] Update organization slug to avoid colision --- integration/tests/session-tasks-sign-in.test.ts | 8 ++++++-- integration/tests/session-tasks-sign-up.test.ts | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts index c34782d3cbd..7502fdee7be 100644 --- a/integration/tests/session-tasks-sign-in.test.ts +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -48,7 +48,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( await u.page.goToRelative('/page-protected'); // 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(); @@ -82,7 +84,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( // Resolves task await u.po.signIn.waitForMounted(); - const fakeOrganization = u.services.organizations.createFakeOrganization(); + 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(); diff --git a/integration/tests/session-tasks-sign-up.test.ts b/integration/tests/session-tasks-sign-up.test.ts index 8f3be4dbeeb..aad5e5fd3c2 100644 --- a/integration/tests/session-tasks-sign-up.test.ts +++ b/integration/tests/session-tasks-sign-up.test.ts @@ -42,7 +42,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( expect(u.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-up', + }); await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization); await u.po.expect.toHaveResolvedTask(); From 26448fcc3dd7117906d7d65ead8d1b74934ed19e Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Fri, 11 Jul 2025 16:38:47 -0300 Subject: [PATCH 12/13] Update bundlewatch --- 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 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" }, From 0a8fdea005360772ad0212442859588445da4144 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Fri, 11 Jul 2025 18:00:03 -0300 Subject: [PATCH 13/13] Decrease flakyness when switching account --- integration/tests/session-tasks-multi-session.test.ts | 5 +++-- .../src/playwright/unstable/page-objects/userButton.ts | 7 ++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/integration/tests/session-tasks-multi-session.test.ts b/integration/tests/session-tasks-multi-session.test.ts index 2b222073265..172c15819ca 100644 --- a/integration/tests/session-tasks-multi-session.test.ts +++ b/integration/tests/session-tasks-multi-session.test.ts @@ -80,10 +80,11 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( await u.po.userButton.waitForMounted(); await u.po.userButton.toggleTrigger(); await u.po.userButton.waitForPopover(); - await u.po.userButton.switchAccount(user2.firstName); + await u.po.userButton.toHaveVisibleMenuItems([/Manage account/i, /Sign out$/i]); + await u.po.userButton.switchAccount(user2.email); // Resolve task - await u.page.waitForAppUrl('/sign-in/tasks/add-organization'); + await u.po.signIn.waitForMounted(); const fakeOrganization2 = u.services.organizations.createFakeOrganization(); await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization2); await u.po.expect.toHaveResolvedTask(); diff --git a/packages/testing/src/playwright/unstable/page-objects/userButton.ts b/packages/testing/src/playwright/unstable/page-objects/userButton.ts index fadfb6b81c3..69804cc4ef3 100644 --- a/packages/testing/src/playwright/unstable/page-objects/userButton.ts +++ b/packages/testing/src/playwright/unstable/page-objects/userButton.ts @@ -32,11 +32,8 @@ export const createUserButtonPageObject = (testArgs: { page: EnhancedPage }) => triggerManageAccount: () => { return page.getByRole('menuitem', { name: /Manage account/i }).click(); }, - switchAccount: (username: string) => { - return page - .getByRole('menuitem') - .filter({ has: page.locator('span', { hasText: username }) }) - .click(); + switchAccount: (emailAddress: string) => { + return page.getByText(emailAddress).click(); }, };