From c10c3e36e7235759046c9cd3d3330c90ae816b8b Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 4 Mar 2025 12:09:58 -0300 Subject: [PATCH 01/16] Add first draft of integration tests --- integration/.keys.json.sample | 4 ++ integration/presets/envs.ts | 8 +++ integration/presets/longRunningApps.ts | 5 ++ .../tests/session-tasks-sign-in.test.ts | 70 +++++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 integration/tests/session-tasks-sign-in.test.ts diff --git a/integration/.keys.json.sample b/integration/.keys.json.sample index caa70922c39..1cb3b47dc50 100644 --- a/integration/.keys.json.sample +++ b/integration/.keys.json.sample @@ -46,5 +46,9 @@ "with-waitlist-mode": { "pk": "", "sk": "" + }, + "with-session-tasks": { + "pk": "", + "sk": "" } } diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 4fbf4d2bb22..8254244547f 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -137,6 +137,13 @@ const withSignInOrUpwithRestrictedModeFlow = withEmailCodes .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-restricted-mode').pk) .setEnvVariable('public', 'CLERK_SIGN_UP_URL', undefined); +const withSessionTasks = base + .clone() + .setId('withSessionTasks') + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks').pk) + .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key'); + export const envs = { base, withKeyless, @@ -157,4 +164,5 @@ export const envs = { withSignInOrUpFlow, withSignInOrUpEmailLinksFlow, withSignInOrUpwithRestrictedModeFlow, + withSessionTasks, } as const; diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index d5573f015e0..f595591a3c3 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -37,6 +37,11 @@ export const createLongRunningApps = () => { config: next.appRouter, env: envs.withSignInOrUpEmailLinksFlow, }, + { + id: 'next.appRouter.withSessionTasks', + config: next.appRouter, + env: envs.withSessionTasks, + }, { id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart }, { id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes }, { id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles }, diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts new file mode 100644 index 00000000000..4cb25c09f96 --- /dev/null +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from '@playwright/test'; + +import type { Application } from '../models/application'; +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils } from '../testUtils'; + +test.describe('session tasks sign in flow @nextjs', () => { + test.describe.configure({ mode: 'serial' }); + let app: Application; + let fakeUser: FakeUser; + + test.beforeAll(async () => { + app = await appConfigs.next.appRouter.clone().commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withSessionTasks); + await app.dev(); + + const m = createTestUtils({ app }); + fakeUser = m.services.users.createFakeUser({ + withPhoneNumber: true, + withUsername: true, + }); + await m.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('on after sign-in, navigates to task', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + 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(); + await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible(); + expect(u.page.url()).toContain('/sign-in/add-organization'); + }); + + test('redirects back to task when accessing root sign in component', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + 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(); + await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible(); + expect(u.page.url()).toContain('/sign-in/add-organization'); + await u.po.signIn.goTo(); + await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible(); + expect(u.page.url()).toContain('/sign-in/add-organization'); + }); + + test.fixme('redirects to after sign-in url when accessing root sign in component with a active session', { + // todo + }); + + test.fixme('redirects to after sign-in url once resolving task', () => { + // todo + }); + + test.fixme('without a session, does not allow to access task component', async () => { + // todo + }); +}); From 0fc67c8bdbdd39ccd872739f39e26bd8e5625824 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 4 Mar 2025 13:24:09 -0300 Subject: [PATCH 02/16] Update `setActive` to handle `pending` session status --- packages/clerk-js/src/core/clerk.ts | 36 +++++++++++++++++++ .../clerk-js/src/ui/common/withRedirect.tsx | 15 +++----- packages/types/src/clerk.ts | 2 ++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 454792854d8..bc569d6f92b 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -44,6 +44,7 @@ import type { OrganizationProfileProps, OrganizationResource, OrganizationSwitcherProps, + PendingSessionResource, PublicKeyCredentialCreationOptionsWithoutExtensions, PublicKeyCredentialRequestOptionsWithoutExtensions, PublicKeyCredentialWithAuthenticatorAssertionResponse, @@ -199,6 +200,7 @@ export class Clerk implements ClerkInterface { #options: ClerkOptions = {}; #pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null; #touchThrottledUntil = 0; + #internalComponentNavigate: ((to: string) => Promise<unknown>) | null = null; public __internal_getCachedResources: | (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>) @@ -436,6 +438,27 @@ export class Clerk implements ClerkInterface { await onAfterSetActive(); }; + #handlePendingSession = async (session: PendingSessionResource) => { + if (!session.currentTask || !this.environment) { + return; + } + + if (session?.lastActiveToken) { + eventBus.dispatch(events.TokenUpdate, { token: session.lastActiveToken }); + } + + if (this.#internalComponentNavigate) { + // Handles navigation for UI components + await this.#internalComponentNavigate(session.currentTask.__internal_getPath()); + } else { + // Handles navigation for custom flows + await this.navigate(session.currentTask.__internal_getUrl(this.#options, this.environment)); + } + + this.#setAccessors(session); + this.#emit(); + }; + /** * Clears the router cache for `@clerk/nextjs` on all routes except the current one. * Note: Calling `onBeforeSetActive` before signing out, allows for new RSC prefetch requests to render as signed in. @@ -956,6 +979,11 @@ export class Clerk implements ClerkInterface { let newSession = session === undefined ? this.session : session; + if (newSession?.status === 'pending') { + await this.#handlePendingSession(newSession); + return; + } + // At this point, the `session` variable should contain either an `SignedInSessionResource` // ,`null` or `undefined`. // We now want to set the last active organization id on that session (if it exists). @@ -1070,6 +1098,14 @@ export class Clerk implements ClerkInterface { return unsubscribe; }; + public __internal_setComponentNavigate = (navigate: (to: string) => Promise<unknown>): UnsubscribeCallback => { + this.#internalComponentNavigate = navigate; + const unsubscribe = () => { + this.#internalComponentNavigate = null; + }; + return unsubscribe; + }; + public navigate = async (to: string | undefined, options?: NavigateOptions): Promise<unknown> => { if (!to || !inBrowser()) { return; diff --git a/packages/clerk-js/src/ui/common/withRedirect.tsx b/packages/clerk-js/src/ui/common/withRedirect.tsx index 43ec9b95172..80337f4567b 100644 --- a/packages/clerk-js/src/ui/common/withRedirect.tsx +++ b/packages/clerk-js/src/ui/common/withRedirect.tsx @@ -28,7 +28,10 @@ export function withRedirect<P extends AvailableComponentProps>( const environment = useEnvironment(); const options = useOptions(); - const shouldRedirect = condition(clerk, environment, options); + const hasTaskAndSingleSessionMode = !!clerk.session?.tasks && environment?.authConfig.singleSessionMode; + const shouldRedirect = hasTaskAndSingleSessionMode || condition(clerk, environment, options); + const redirectUrlWithDefault = hasTaskAndSingleSessionMode ? () => clerk.buildSessionTaskUrl() : redirectUrl; + React.useEffect(() => { if (shouldRedirect) { if (warning && isDevelopmentFromPublishableKey(clerk.publishableKey)) { @@ -36,7 +39,7 @@ export function withRedirect<P extends AvailableComponentProps>( } // TODO: Fix this properly // eslint-disable-next-line @typescript-eslint/no-floating-promises - navigate(redirectUrl({ clerk, environment, options })); + navigate(redirectUrlWithDefault({ clerk, environment, options })); } }, []); @@ -89,11 +92,3 @@ export const withRedirectToAfterSignUp = <P extends AvailableComponentProps>(Com return HOC; }; - -export const withRedirectToHomeSingleSessionGuard = <P extends AvailableComponentProps>(Component: ComponentType<P>) => - withRedirect( - Component, - sessionExistsAndSingleSessionModeEnabled, - ({ environment }) => environment.displayConfig.homeUrl, - warnings.cannotRenderComponentWhenSessionExists, - ); diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 29834688505..2e675fc4f52 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -500,6 +500,8 @@ export interface Clerk { */ buildWaitlistUrl(opts?: { initialValues?: Record<string, string> }): string; + buildSessionTaskUrl(): string; + /** * * Redirects to the provided url after decorating it with the auth token for development instances. From a0fa5c2a1299b4d39160a36a673427449facc348 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:35:33 -0300 Subject: [PATCH 03/16] Handle internal component navigation --- .../tests/session-tasks-sign-in.test.ts | 29 ++------- packages/clerk-js/src/core/clerk.ts | 42 ++++++------- packages/clerk-js/src/core/events.ts | 11 +++- .../clerk-js/src/core/resources/Session.ts | 9 ++- .../src/core/resources/SessionTask.ts | 61 +++++++++++++++++++ packages/clerk-js/src/core/warnings.ts | 4 ++ .../clerk-js/src/ui/common/withRedirect.tsx | 55 ++++++++++++++--- .../ui/components/SessionTask/SessionTask.tsx | 34 +++++++++++ .../src/ui/components/SessionTask/index.ts | 1 + .../src/ui/components/SignIn/SignIn.tsx | 32 +++++++++- .../src/ui/components/SignUp/SignUp.tsx | 13 ++++ .../src/ui/contexts/components/SignIn.ts | 19 ++++++ .../src/ui/contexts/components/SignUp.ts | 19 ++++++ .../src/ui/hooks/useNavigateOnEvent.ts | 45 ++++++++++++++ .../clerk-js/src/utils/componentGuards.ts | 4 ++ packages/types/src/clerk.ts | 2 - packages/types/src/json.ts | 8 ++- packages/types/src/session.ts | 14 ++++- packages/types/src/snapshots.ts | 3 + 19 files changed, 340 insertions(+), 65 deletions(-) create mode 100644 packages/clerk-js/src/core/resources/SessionTask.ts create mode 100644 packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx create mode 100644 packages/clerk-js/src/ui/components/SessionTask/index.ts create mode 100644 packages/clerk-js/src/ui/hooks/useNavigateOnEvent.ts diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts index 4cb25c09f96..a4514c1ec2a 100644 --- a/integration/tests/session-tasks-sign-in.test.ts +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { test } from '@playwright/test'; import type { Application } from '../models/application'; import { appConfigs } from '../presets'; @@ -29,31 +29,12 @@ test.describe('session tasks sign in flow @nextjs', () => { await app.teardown(); }); - test('on after sign-in, navigates to task', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - 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(); - await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible(); - expect(u.page.url()).toContain('/sign-in/add-organization'); + test.fixme('on after sign-in, navigates to task', async () => { + // todo }); - test('redirects back to task when accessing root sign in component', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - 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(); - await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible(); - expect(u.page.url()).toContain('/sign-in/add-organization'); - await u.po.signIn.goTo(); - await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible(); - expect(u.page.url()).toContain('/sign-in/add-organization'); + test.fixme('redirects back to task when accessing root sign in component', async () => { + // todo }); test.fixme('redirects to after sign-in url when accessing root sign in component with a active session', { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index bc569d6f92b..a0c54ac931a 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -438,27 +438,6 @@ export class Clerk implements ClerkInterface { await onAfterSetActive(); }; - #handlePendingSession = async (session: PendingSessionResource) => { - if (!session.currentTask || !this.environment) { - return; - } - - if (session?.lastActiveToken) { - eventBus.dispatch(events.TokenUpdate, { token: session.lastActiveToken }); - } - - if (this.#internalComponentNavigate) { - // Handles navigation for UI components - await this.#internalComponentNavigate(session.currentTask.__internal_getPath()); - } else { - // Handles navigation for custom flows - await this.navigate(session.currentTask.__internal_getUrl(this.#options, this.environment)); - } - - this.#setAccessors(session); - this.#emit(); - }; - /** * Clears the router cache for `@clerk/nextjs` on all routes except the current one. * Note: Calling `onBeforeSetActive` before signing out, allows for new RSC prefetch requests to render as signed in. @@ -1071,6 +1050,27 @@ export class Clerk implements ClerkInterface { await onAfterSetActive(); }; + #handlePendingSession = async (session: PendingSessionResource) => { + if (!session.currentTask || !this.environment) { + return; + } + + if (session?.lastActiveToken) { + eventBus.dispatch(events.TokenUpdate, { token: session.lastActiveToken }); + } + + if (this.#internalComponentNavigate) { + // Handles navigation for UI components + await this.#internalComponentNavigate(session.currentTask.__internal_getPath()); + } else { + // Handles navigation for custom flows + await this.navigate(session.currentTask.__internal_getUrl(this.#options, this.environment)); + } + + this.#setAccessors(session); + this.#emit(); + }; + public addListener = (listener: ListenerCallback): UnsubscribeCallback => { listener = memoizeListenerCallback(listener); this.#listeners.push(listener); diff --git a/packages/clerk-js/src/core/events.ts b/packages/clerk-js/src/core/events.ts index 7401dd91370..4982ed35da0 100644 --- a/packages/clerk-js/src/core/events.ts +++ b/packages/clerk-js/src/core/events.ts @@ -1,18 +1,21 @@ -import type { TokenResource } from '@clerk/types'; +import type { SessionResource, TokenResource } from '@clerk/types'; export const events = { TokenUpdate: 'token:update', UserSignOut: 'user:signOut', + InternalComponentNavigate: 'task:internalNavigate', } as const; type ClerkEvent = (typeof events)[keyof typeof events]; type EventHandler<E extends ClerkEvent> = (payload: EventPayload[E]) => void; type TokenUpdatePayload = { token: TokenResource | null }; +type InternalComponentNavigatePayload = { resolveNavigation: () => void; session: SessionResource }; type EventPayload = { [events.TokenUpdate]: TokenUpdatePayload; [events.UserSignOut]: null; + [events.InternalComponentNavigate]: InternalComponentNavigatePayload; }; const createEventBus = () => { @@ -45,7 +48,11 @@ const createEventBus = () => { eventToHandlersMap.set(event, []); }; - return { on, dispatch, off }; + const has = <E extends ClerkEvent>(event: E) => { + return !!eventToHandlersMap.has(event); + }; + + return { on, dispatch, off, has }; }; export const eventBus = createEventBus(); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index d3972d75327..5f530d76789 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -13,7 +13,6 @@ import type { SessionJSONSnapshot, SessionResource, SessionStatus, - SessionTask, SessionVerificationJSON, SessionVerificationResource, SessionVerifyAttemptFirstFactorParams, @@ -35,6 +34,7 @@ import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../e import { eventBus, events } from '../events'; import { SessionTokenCache } from '../tokenCache'; import { BaseResource, PublicUserData, Token, User } from './internal'; +import { SessionTask } from './SessionTask'; import { SessionVerification } from './SessionVerification'; export class Session extends BaseResource implements SessionResource { @@ -286,7 +286,7 @@ export class Session extends BaseResource implements SessionResource { this.createdAt = unixEpochToDate(data.created_at); this.updatedAt = unixEpochToDate(data.updated_at); this.user = new User(data.user); - this.tasks = data.tasks; + this.tasks = data.tasks?.map(task => new SessionTask(task)) ?? []; if (data.public_user_data) { this.publicUserData = new PublicUserData(data.public_user_data); @@ -363,4 +363,9 @@ export class Session extends BaseResource implements SessionResource { return token.getRawString() || null; }); } + + get currentTask() { + const [task] = this.tasks ?? []; + return task; + } } diff --git a/packages/clerk-js/src/core/resources/SessionTask.ts b/packages/clerk-js/src/core/resources/SessionTask.ts new file mode 100644 index 00000000000..af4760ba012 --- /dev/null +++ b/packages/clerk-js/src/core/resources/SessionTask.ts @@ -0,0 +1,61 @@ +import type { + ClerkOptions, + EnvironmentResource, + SessionTaskJSON, + SessionTaskJSONSnapshot, + SessionTaskKey, + SessionTaskResource, +} from '@clerk/types'; + +import { buildURL, inBrowser } from '../../utils'; + +export const SESSION_TASK_PATHS = ['add-organization'] as const; +type SessionTaskPath = (typeof SESSION_TASK_PATHS)[number]; + +export const SESSION_TASK_PATH_BY_KEY: Record<SessionTaskKey, SessionTaskPath> = { + org: 'add-organization', +} as const; + +export class SessionTask implements SessionTaskResource { + key!: SessionTaskKey; + + constructor(data: SessionTaskJSON | SessionTaskJSONSnapshot) { + this.fromJSON(data); + } + + protected fromJSON(data: SessionTaskJSON | SessionTaskJSONSnapshot): this { + if (!data) { + return this; + } + + this.key = data.key; + + return this; + } + + public __internal_toSnapshot(): SessionTaskJSONSnapshot { + return { + key: this.key, + }; + } + + public __internal_getUrlPath(): `/${SessionTaskPath}` { + return `/${SESSION_TASK_PATH_BY_KEY[this.key]}`; + } + + public __internal_getAbsoluteUrl(options: ClerkOptions, environment?: EnvironmentResource | null): string { + if (!environment || !inBrowser()) { + return ''; + } + + const signInUrl = options['signInUrl'] || environment.displayConfig.signInUrl; + const signUpUrl = options['signUpUrl'] || environment.displayConfig.signUpUrl; + const isReferrerSignUpUrl = window.location.href.startsWith(signUpUrl); + + return buildURL( + // TODO - Introduce custom `tasksUrl` option to be used as a base path fallback for custom flows + { base: isReferrerSignUpUrl ? signUpUrl : signInUrl, hashPath: this.__internal_getUrlPath() }, + { stringify: true }, + ); + } +} diff --git a/packages/clerk-js/src/core/warnings.ts b/packages/clerk-js/src/core/warnings.ts index 1fd500a1e91..a3eefbcd9f8 100644 --- a/packages/clerk-js/src/core/warnings.ts +++ b/packages/clerk-js/src/core/warnings.ts @@ -21,6 +21,10 @@ const warnings = { cannotRenderComponentWhenUserDoesNotExist: '<UserProfile/> cannot render unless a user is signed in. Since no user is signed in, this is no-op.', cannotRenderComponentWhenOrgDoesNotExist: `<OrganizationProfile/> cannot render unless an organization is active. Since no organization is currently active, this is no-op.`, + cannotRenderSessionTaskComponentOnSignIn: + 'Cannot render component unless a session task exists. Clerk is redirecting to `signInUrl` instead.', + cannotRenderSessionTaskComponentOnSignUp: + 'Cannot render component unless a session task exists. Clerk is redirecting to `signUpUrl` instead.', cannotRenderAnyOrganizationComponent: createMessageForDisabledOrganizations, cannotOpenUserProfile: 'The UserProfile modal cannot render unless a user is signed in. Since no user is signed in, this is no-op.', diff --git a/packages/clerk-js/src/ui/common/withRedirect.tsx b/packages/clerk-js/src/ui/common/withRedirect.tsx index 80337f4567b..85d73efb3e6 100644 --- a/packages/clerk-js/src/ui/common/withRedirect.tsx +++ b/packages/clerk-js/src/ui/common/withRedirect.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { warnings } from '../../core/warnings'; import type { ComponentGuard } from '../../utils'; -import { sessionExistsAndSingleSessionModeEnabled } from '../../utils'; +import { noTaskExists, sessionExistsAndSingleSessionModeEnabled } from '../../utils'; import { useEnvironment, useOptions, useSignInContext, useSignUpContext } from '../contexts'; import { useRouter } from '../router'; import type { AvailableComponentProps } from '../types'; @@ -28,10 +28,7 @@ export function withRedirect<P extends AvailableComponentProps>( const environment = useEnvironment(); const options = useOptions(); - const hasTaskAndSingleSessionMode = !!clerk.session?.tasks && environment?.authConfig.singleSessionMode; - const shouldRedirect = hasTaskAndSingleSessionMode || condition(clerk, environment, options); - const redirectUrlWithDefault = hasTaskAndSingleSessionMode ? () => clerk.buildSessionTaskUrl() : redirectUrl; - + const shouldRedirect = condition(clerk, environment, options); React.useEffect(() => { if (shouldRedirect) { if (warning && isDevelopmentFromPublishableKey(clerk.publishableKey)) { @@ -39,7 +36,7 @@ export function withRedirect<P extends AvailableComponentProps>( } // TODO: Fix this properly // eslint-disable-next-line @typescript-eslint/no-floating-promises - navigate(redirectUrlWithDefault({ clerk, environment, options })); + navigate(redirectUrl({ clerk, environment, options })); } }, []); @@ -64,7 +61,7 @@ export const withRedirectToAfterSignIn = <P extends AvailableComponentProps>(Com return withRedirect( Component, sessionExistsAndSingleSessionModeEnabled, - ({ clerk }) => signInCtx.afterSignInUrl || clerk.buildAfterSignInUrl(), + ({ clerk }) => signInCtx.tasksUrl || signInCtx.afterSignInUrl || clerk.buildAfterSignInUrl(), warnings.cannotRenderSignInComponentWhenSessionExists, )(props); }; @@ -83,7 +80,7 @@ export const withRedirectToAfterSignUp = <P extends AvailableComponentProps>(Com return withRedirect( Component, sessionExistsAndSingleSessionModeEnabled, - ({ clerk }) => signUpCtx.afterSignUpUrl || clerk.buildAfterSignUpUrl(), + ({ clerk }) => signUpCtx.tasksUrl || signUpCtx.afterSignUpUrl || clerk.buildAfterSignUpUrl(), warnings.cannotRenderSignUpComponentWhenSessionExists, )(props); }; @@ -92,3 +89,45 @@ export const withRedirectToAfterSignUp = <P extends AvailableComponentProps>(Com return HOC; }; + +export const withRedirectToSignUpIfNoTasksAvailable = <P extends AvailableComponentProps>( + Component: ComponentType<P>, +) => { + const displayName = Component.displayName || Component.name || 'Component'; + Component.displayName = displayName; + + const HOC = (props: P) => { + const signUpCtx = useSignUpContext(); + return withRedirect( + Component, + noTaskExists, + ({ clerk }) => signUpCtx.signUpUrl || clerk.buildSignUpUrl(), + warnings.cannotRenderSessionTaskComponentOnSignUp, + )(props); + }; + + HOC.displayName = `withRedirectToSignUpIfNoTasksAvailable(${displayName})`; + + return HOC; +}; + +export const withRedirectToSignInIfNoTasksAvailable = <P extends AvailableComponentProps>( + Component: ComponentType<P>, +) => { + const displayName = Component.displayName || Component.name || 'Component'; + Component.displayName = displayName; + + const HOC = (props: P) => { + const signInCtx = useSignInContext(); + return withRedirect( + Component, + noTaskExists, + ({ clerk }) => signInCtx.signInUrl || clerk.buildSignInUrl(), + warnings.cannotRenderSessionTaskComponentOnSignUp, + )(props); + }; + + HOC.displayName = `withRedirectToSignInIfNoTasksAvailable(${displayName})`; + + return HOC; +}; diff --git a/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx b/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx new file mode 100644 index 00000000000..1a74f54e1cd --- /dev/null +++ b/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx @@ -0,0 +1,34 @@ +import { useSessionContext } from '@clerk/shared/react/index'; +import type { SessionTaskKey } from '@clerk/types'; +import { type ComponentType } from 'react'; + +import { OrganizationListContext } from '../../contexts'; +import { OrganizationList } from '../OrganizationList'; + +/** + * @internal + */ +const SessionTaskRegistry: Record<SessionTaskKey, ComponentType> = { + org: () => ( + // TODO - Hide personal workspace within organization list context based on environment + <OrganizationListContext.Provider value={{ componentName: 'OrganizationList', hidePersonal: true }}> + <OrganizationList /> + </OrganizationListContext.Provider> + ), +}; + +/** + * @internal + */ +export function SessionTask(): React.ReactNode { + const session = useSessionContext(); + const [currentTask] = session?.tasks ?? []; + + if (!currentTask) { + return null; + } + + const Content = SessionTaskRegistry[currentTask.key]; + + return Content ? <Content /> : null; +} diff --git a/packages/clerk-js/src/ui/components/SessionTask/index.ts b/packages/clerk-js/src/ui/components/SessionTask/index.ts new file mode 100644 index 00000000000..ddae613a6e2 --- /dev/null +++ b/packages/clerk-js/src/ui/components/SessionTask/index.ts @@ -0,0 +1 @@ +export * from './SessionTask'; diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index c30b60ed5a0..e5c5a5263e5 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -2,7 +2,9 @@ import { useClerk } from '@clerk/shared/react'; import type { SignInModalProps, SignInProps } from '@clerk/types'; import React from 'react'; +import { SESSION_TASK_PATHS, SessionTask } from '../../../core/resources/SessionTask'; import { normalizeRoutingOptions } from '../../../utils/normalizeRoutingOptions'; +import { withRedirectToSignInIfNoTasksAvailable } from '../../common'; import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; import type { SignUpContextType } from '../../contexts'; import { @@ -80,7 +82,6 @@ function SignInRoutes(): JSX.Element { redirectUrl='../factor-two' /> </Route> - {signInContext.isCombinedFlow && ( <Route path='create'> <Route @@ -128,16 +129,43 @@ function SignInRoutes(): JSX.Element { > <LazySignUpVerifyPhone /> </Route> + {SESSION_TASK_PATHS.map(path => ( + <Route + path={path} + key={path} + > + <SignInSessionTask /> + </Route> + ))} <Route index> <LazySignUpContinue /> </Route> </Route> + + {SESSION_TASK_PATHS.map(path => ( + <Route + path={path} + key={path} + > + <SignInSessionTask /> + </Route> + ))} + <Route index> <LazySignUpStart /> </Route> </Route> )} + {SESSION_TASK_PATHS.map(path => ( + <Route + path={path} + key={path} + > + <SignInSessionTask /> + </Route> + ))} + <Route index> <SignInStart /> </Route> @@ -209,3 +237,5 @@ export const SignInModal = (props: SignInModalProps): JSX.Element => { </Route> ); }; + +const SignInSessionTask = withRedirectToSignInIfNoTasksAvailable(SessionTask); diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx index c96abac836b..12ca62f4948 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx @@ -2,10 +2,13 @@ import { useClerk } from '@clerk/shared/react'; import type { SignUpModalProps, SignUpProps } from '@clerk/types'; import React from 'react'; +import { SESSION_TASK_PATHS } from '../../../core/resources/SessionTask'; +import { withRedirectToSignUpIfNoTasksAvailable } from '../../../ui/common'; import { SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; import { SignUpContext, useSignUpContext, withCoreSessionSwitchGuard } from '../../contexts'; import { Flow } from '../../customizables'; import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; +import { SessionTask } from '../SessionTask'; import { SignUpContinue } from './SignUpContinue'; import { SignUpSSOCallback } from './SignUpSSOCallback'; import { SignUpStart } from './SignUpStart'; @@ -74,6 +77,14 @@ function SignUpRoutes(): JSX.Element { <SignUpContinue /> </Route> </Route> + {SESSION_TASK_PATHS.map(path => ( + <Route + path={path} + key={path} + > + <SignUpSessionTask /> + </Route> + ))} <Route index> <SignUpStart /> </Route> @@ -118,4 +129,6 @@ export const SignUpModal = (props: SignUpModalProps): JSX.Element => { ); }; +const SignUpSessionTask = withRedirectToSignUpIfNoTasksAvailable(SessionTask); + export { SignUpContinue, SignUpSSOCallback, SignUpStart, SignUpVerifyEmail, SignUpVerifyPhone }; diff --git a/packages/clerk-js/src/ui/contexts/components/SignIn.ts b/packages/clerk-js/src/ui/contexts/components/SignIn.ts index 564f755992c..48018f1b574 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignIn.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignIn.ts @@ -7,6 +7,7 @@ import { buildURL } from '../../../utils'; import { RedirectUrls } from '../../../utils/redirectUrls'; import { buildRedirectUrl, MAGIC_LINK_VERIFY_PATH_ROUTE, SSO_CALLBACK_PATH_ROUTE } from '../../common/redirects'; import { useEnvironment, useOptions } from '../../contexts'; +import { useNavigateOnEvent } from '../../hooks/useNavigateOnEvent'; import type { ParsedQueryString } from '../../router'; import { useRouter } from '../../router'; import type { SignInCtx } from '../../types'; @@ -21,6 +22,7 @@ export type SignInContextType = SignInCtx & { authQueryString: string | null; afterSignUpUrl: string; afterSignInUrl: string; + tasksUrl: string | null; transferable: boolean; waitlistUrl: string; emailLinkRedirectUrl: string; @@ -112,6 +114,22 @@ export const useSignInContext = (): SignInContextType => { const signUpContinueUrl = buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true }); + const tasksUrl = clerk.session?.currentTask + ? buildRedirectUrl({ + routing: ctx.routing, + baseUrl: signInUrl, + path: ctx.path, + endpoint: clerk.session?.currentTask?.__internal_getUrlPath(), + authQueryString: null, + }) + : null; + + useNavigateOnEvent({ + routing: ctx.routing, + baseUrl: signInUrl, + path: ctx.path, + }); + return { ...(ctx as SignInCtx), transferable: ctx.transferable ?? true, @@ -123,6 +141,7 @@ export const useSignInContext = (): SignInContextType => { afterSignUpUrl, emailLinkRedirectUrl, ssoCallbackUrl, + tasksUrl, navigateAfterSignIn, signUpContinueUrl, queryParams, diff --git a/packages/clerk-js/src/ui/contexts/components/SignUp.ts b/packages/clerk-js/src/ui/contexts/components/SignUp.ts index 32b4e4794c0..4239965ecde 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignUp.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignUp.ts @@ -7,6 +7,7 @@ import { buildURL } from '../../../utils'; import { RedirectUrls } from '../../../utils/redirectUrls'; import { buildRedirectUrl, MAGIC_LINK_VERIFY_PATH_ROUTE, SSO_CALLBACK_PATH_ROUTE } from '../../common/redirects'; import { useEnvironment, useOptions } from '../../contexts'; +import { useNavigateOnEvent } from '../../hooks/useNavigateOnEvent'; import type { ParsedQueryString } from '../../router'; import { useRouter } from '../../router'; import type { SignUpCtx } from '../../types'; @@ -22,6 +23,7 @@ export type SignUpContextType = SignUpCtx & { afterSignUpUrl: string; afterSignInUrl: string; waitlistUrl: string; + tasksUrl: string | null; isCombinedFlow: boolean; emailLinkRedirectUrl: string; ssoCallbackUrl: string; @@ -107,6 +109,22 @@ export const useSignUpContext = (): SignUpContextType => { // TODO: Avoid building this url again to remove duplicate code. Get it from window.Clerk instead. const secondFactorUrl = buildURL({ base: signInUrl, hashPath: '/factor-two' }, { stringify: true }); + const tasksUrl = clerk.session?.currentTask + ? buildRedirectUrl({ + routing: ctx.routing, + baseUrl: signUpUrl, + path: ctx.path, + endpoint: clerk.session?.currentTask?.__internal_getUrlPath(), + authQueryString: null, + }) + : null; + + useNavigateOnEvent({ + routing: ctx.routing, + baseUrl: signUpUrl, + path: ctx.path, + }); + return { ...ctx, componentName, @@ -118,6 +136,7 @@ export const useSignUpContext = (): SignUpContextType => { afterSignInUrl, emailLinkRedirectUrl, ssoCallbackUrl, + tasksUrl, navigateAfterSignUp, queryParams, initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, diff --git a/packages/clerk-js/src/ui/hooks/useNavigateOnEvent.ts b/packages/clerk-js/src/ui/hooks/useNavigateOnEvent.ts new file mode 100644 index 00000000000..09e1e37f5f1 --- /dev/null +++ b/packages/clerk-js/src/ui/hooks/useNavigateOnEvent.ts @@ -0,0 +1,45 @@ +import type { SessionResource } from '@clerk/types'; +import { useEffect } from 'react'; + +import { eventBus, events } from '../../core/events'; +import { buildRedirectUrl } from '../common'; +import { useRouter } from '../router'; + +type UseNavigateOnEventOptions = Pick<Parameters<typeof buildRedirectUrl>[0], 'routing' | 'baseUrl' | 'path'>; + +/** + * Custom hook to trigger internal component navigation by a event. + */ +export const useNavigateOnEvent = ({ routing, baseUrl, path }: UseNavigateOnEventOptions) => { + const { navigate } = useRouter(); + + useEffect(() => { + const handleNavigation = ({ + resolveNavigation, + session, + }: { + resolveNavigation: () => void; + session: SessionResource; + }) => { + if (!session.currentTask) { + return; + } + + void navigate( + buildRedirectUrl({ + routing, + baseUrl, + path, + endpoint: session.currentTask.__internal_getUrlPath(), + authQueryString: null, + }), + ).then(resolveNavigation); + }; + + eventBus.on(events.InternalComponentNavigate, handleNavigation); + + return () => { + eventBus.off(events.InternalComponentNavigate, handleNavigation); + }; + }, []); +}; diff --git a/packages/clerk-js/src/utils/componentGuards.ts b/packages/clerk-js/src/utils/componentGuards.ts index f0084a6a47d..07b1c08e751 100644 --- a/packages/clerk-js/src/utils/componentGuards.ts +++ b/packages/clerk-js/src/utils/componentGuards.ts @@ -14,6 +14,10 @@ export const noUserExists: ComponentGuard = clerk => { return !clerk.user; }; +export const noTaskExists: ComponentGuard = clerk => { + return !clerk.session?.currentTask; +}; + export const noOrganizationExists: ComponentGuard = clerk => { return !clerk.organization; }; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 2e675fc4f52..29834688505 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -500,8 +500,6 @@ export interface Clerk { */ buildWaitlistUrl(opts?: { initialValues?: Record<string, string> }): string; - buildSessionTaskUrl(): string; - /** * * Redirects to the provided url after decorating it with the auth token for development instances. diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 21f378f0cf3..afab7bcf0f6 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -13,7 +13,7 @@ import type { OrganizationCustomRoleKey, OrganizationPermissionKey } from './org import type { OrganizationSettingsJSON } from './organizationSettings'; import type { OrganizationSuggestionStatus } from './organizationSuggestion'; import type { SamlIdpSlug } from './saml'; -import type { SessionStatus, SessionTask } from './session'; +import type { SessionStatus, SessionTaskKey } from './session'; import type { SessionVerificationLevel, SessionVerificationStatus } from './sessionVerification'; import type { SignInFirstFactor, SignInJSON, SignInSecondFactor } from './signIn'; import type { SignUpField, SignUpIdentificationField, SignUpStatus } from './signUp'; @@ -103,6 +103,10 @@ export interface SignUpJSON extends ClerkResourceJSON { verifications: SignUpVerificationsJSON | null; } +export interface SessionTaskJSON { + key: SessionTaskKey; +} + export interface SessionJSON extends ClerkResourceJSON { object: 'session'; id: string; @@ -119,7 +123,7 @@ export interface SessionJSON extends ClerkResourceJSON { last_active_token: TokenJSON; last_active_organization_id: string | null; actor: ActJWTClaim | null; - tasks: Array<SessionTask> | null; + tasks: Array<SessionTaskJSON> | null; user: UserJSON; public_user_data: PublicUserDataJSON; created_at: number; diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index 6f40a118250..5b8a92677e6 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -1,3 +1,6 @@ +import type { ClerkOptions } from 'clerk'; + +import type { EnvironmentResource } from './environment'; import type { BackupCodeAttempt, EmailCodeAttempt, @@ -123,7 +126,8 @@ export interface SessionResource extends ClerkResource { lastActiveOrganizationId: string | null; lastActiveAt: Date; actor: ActJWTClaim | null; - tasks: Array<SessionTask> | null; + tasks: Array<SessionTaskResource> | null; + currentTask?: SessionTaskResource; /** * The user associated with the session. */ @@ -223,8 +227,12 @@ export interface PublicUserData { userId?: string; } -export interface SessionTask { - key: 'orgs'; +export type SessionTaskKey = 'org'; + +export interface SessionTaskResource { + key: SessionTaskKey; + __internal_getUrlPath: () => string; + __internal_getAbsoluteUrl: (options: ClerkOptions, environment?: EnvironmentResource | null) => string; } export type GetTokenOptions = { diff --git a/packages/types/src/snapshots.ts b/packages/types/src/snapshots.ts index bc398bb633e..babd482803c 100644 --- a/packages/types/src/snapshots.ts +++ b/packages/types/src/snapshots.ts @@ -19,6 +19,7 @@ import type { SamlAccountConnectionJSON, SamlAccountJSON, SessionJSON, + SessionTaskJSON, SignUpJSON, SignUpVerificationJSON, SignUpVerificationsJSON, @@ -94,6 +95,8 @@ export type SessionJSONSnapshot = Override< } >; +export type SessionTaskJSONSnapshot = SessionTaskJSON; + export type SignUpJSONSnapshot = Override< Nullable<SignUpJSON, 'status'>, { From 18c7cd8b6842bc2b3ba9bd8330e87ce98988de05 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 5 Mar 2025 14:53:12 -0300 Subject: [PATCH 04/16] Introduce test coverage --- .changeset/brave-pears-add.md | 6 + .../tests/session-tasks-sign-in.test.ts | 38 +- .../tests/session-tasks-sign-up.test.ts | 40 ++ .../clerk-js/src/core/__tests__/clerk.test.ts | 598 ++++++++++-------- packages/clerk-js/src/core/clerk.ts | 51 +- packages/clerk-js/src/core/events.ts | 11 +- .../clerk-js/src/core/resources/Session.ts | 4 +- .../src/core/resources/SessionTask.ts | 61 -- packages/clerk-js/src/core/sessionTasks.ts | 46 ++ packages/clerk-js/src/core/warnings.ts | 4 - packages/clerk-js/src/ui/common/redirects.ts | 19 + .../clerk-js/src/ui/common/withRedirect.tsx | 48 +- .../ui/components/SessionTask/SessionTask.tsx | 19 +- .../src/ui/components/SignIn/SignIn.tsx | 41 +- .../src/ui/components/SignIn/lazy-sign-up.ts | 2 + .../src/ui/components/SignUp/SignUp.tsx | 17 +- .../src/ui/contexts/components/SignIn.ts | 36 +- .../src/ui/contexts/components/SignUp.ts | 36 +- .../src/ui/hooks/useNavigateOnEvent.ts | 45 -- .../clerk-js/src/utils/componentGuards.ts | 4 - packages/react/src/isomorphicClerk.ts | 1 + packages/types/src/clerk.ts | 7 + packages/types/src/json.ts | 8 +- packages/types/src/session.ts | 16 +- packages/types/src/snapshots.ts | 3 - 25 files changed, 574 insertions(+), 587 deletions(-) create mode 100644 .changeset/brave-pears-add.md create mode 100644 integration/tests/session-tasks-sign-up.test.ts delete mode 100644 packages/clerk-js/src/core/resources/SessionTask.ts create mode 100644 packages/clerk-js/src/core/sessionTasks.ts delete mode 100644 packages/clerk-js/src/ui/hooks/useNavigateOnEvent.ts diff --git a/.changeset/brave-pears-add.md b/.changeset/brave-pears-add.md new file mode 100644 index 00000000000..ce2ecccbfc2 --- /dev/null +++ b/.changeset/brave-pears-add.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Navigate to tasks on after sign-in/sign-up diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts index a4514c1ec2a..8e4ae9e7a2b 100644 --- a/integration/tests/session-tasks-sign-in.test.ts +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -1,11 +1,11 @@ -import { test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; import type { Application } from '../models/application'; import { appConfigs } from '../presets'; import type { FakeUser } from '../testUtils'; import { createTestUtils } from '../testUtils'; -test.describe('session tasks sign in flow @nextjs', () => { +test.describe('session tasks after sign-in flow @nextjs', () => { test.describe.configure({ mode: 'serial' }); let app: Application; let fakeUser: FakeUser; @@ -17,10 +17,7 @@ test.describe('session tasks sign in flow @nextjs', () => { await app.dev(); const m = createTestUtils({ app }); - fakeUser = m.services.users.createFakeUser({ - withPhoneNumber: true, - withUsername: true, - }); + fakeUser = m.services.users.createFakeUser(); await m.services.users.createBapiUser(fakeUser); }); @@ -29,23 +26,16 @@ test.describe('session tasks sign in flow @nextjs', () => { await app.teardown(); }); - test.fixme('on after sign-in, navigates to task', async () => { - // todo - }); - - test.fixme('redirects back to task when accessing root sign in component', async () => { - // todo - }); - - test.fixme('redirects to after sign-in url when accessing root sign in component with a active session', { - // todo - }); - - test.fixme('redirects to after sign-in url once resolving task', () => { - // todo - }); - - test.fixme('without a session, does not allow to access task component', async () => { - // todo + test('navigate to task on after sign-in', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + 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(); + + await expect(u.page.getByRole('button', { name: /create organization/i })).toBeVisible(); + expect(page.url()).toContain('add-organization'); }); }); diff --git a/integration/tests/session-tasks-sign-up.test.ts b/integration/tests/session-tasks-sign-up.test.ts new file mode 100644 index 00000000000..6270b8967a1 --- /dev/null +++ b/integration/tests/session-tasks-sign-up.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; + +import type { Application } from '../models/application'; +import { appConfigs } from '../presets'; +import { createTestUtils } from '../testUtils'; + +test.describe('session tasks after sign-up flow @nextjs', () => { + test.describe.configure({ mode: 'serial' }); + let app: Application; + + test.beforeAll(async () => { + app = await appConfigs.next.appRouter.clone().commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withSessionTasks); + await app.dev(); + }); + + test.afterAll(async () => { + await app.teardown(); + }); + + test('navigate to task on after sign-up', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + const fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPhoneNumber: true, + withUsername: true, + }); + await u.po.signUp.goTo(); + await u.po.signUp.signUpWithEmailAndPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + + await expect(u.page.getByRole('button', { name: /create organization/i })).toBeVisible(); + expect(page.url()).toContain('add-organization'); + + await fakeUser.deleteIfExists(); + }); +}); diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 48f0a85a291..ea573685bd0 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -155,163 +155,302 @@ describe('Clerk singleton', () => { }); describe('.setActive', () => { - describe.each(['active', 'pending'] satisfies Array<SignedInSessionResource['status']>)( - 'when session has %s status', - status => { - const mockSession = { - id: '1', - remove: jest.fn(), - status, - user: {}, - touch: jest.fn(() => Promise.resolve()), - getToken: jest.fn(), - lastActiveToken: { getRawString: () => 'mocked-token' }, - }; - let eventBusSpy; + describe('with `active` session status', () => { + const mockSession = { + id: '1', + remove: jest.fn(), + status: 'active', + user: {}, + touch: jest.fn(() => Promise.resolve()), + getToken: jest.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + }; + let eventBusSpy; - beforeEach(() => { - eventBusSpy = jest.spyOn(eventBus, 'dispatch'); - }); + beforeEach(() => { + eventBusSpy = jest.spyOn(eventBus, 'dispatch'); + }); - afterEach(() => { - mockSession.remove.mockReset(); - mockSession.touch.mockReset(); + afterEach(() => { + mockSession.remove.mockReset(); + mockSession.touch.mockReset(); - eventBusSpy?.mockRestore(); - // cleanup global window pollution - (window as any).__unstable__onBeforeSetActive = null; - (window as any).__unstable__onAfterSetActive = null; + eventBusSpy?.mockRestore(); + // cleanup global window pollution + (window as any).__unstable__onBeforeSetActive = null; + (window as any).__unstable__onAfterSetActive = null; + }); + + it('does not call session touch on signOut', async () => { + mockSession.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: null }); + await waitFor(() => { + expect(mockSession.touch).not.toHaveBeenCalled(); + expect(eventBusSpy).toHaveBeenCalledWith('token:update', { token: null }); }); + }); - it('does not call session touch on signOut', async () => { - mockSession.touch.mockReturnValueOnce(Promise.resolve()); - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + it('calls session.touch by default', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); - const sut = new Clerk(productionPublishableKey); - await sut.load(); - await sut.setActive({ session: null }); - await waitFor(() => { - expect(mockSession.touch).not.toHaveBeenCalled(); - expect(eventBusSpy).toHaveBeenCalledWith('token:update', { token: null }); - }); + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + expect(mockSession.touch).toHaveBeenCalled(); + }); + + it('does not call session.touch if Clerk was initialised with touchSession set to false', async () => { + mockSession.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + mockSession.getToken.mockResolvedValue('mocked-token'); + + const sut = new Clerk(productionPublishableKey); + await sut.load({ touchSession: false }); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + await waitFor(() => { + expect(mockSession.touch).not.toHaveBeenCalled(); + expect(mockSession.getToken).toHaveBeenCalled(); }); + }); - it('calls session.touch by default', async () => { - mockSession.touch.mockReturnValue(Promise.resolve()); - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + it('calls __unstable__onBeforeSetActive before session.touch', async () => { + mockSession.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); - const sut = new Clerk(productionPublishableKey); - await sut.load(); - await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + (window as any).__unstable__onBeforeSetActive = () => { + expect(mockSession.touch).not.toHaveBeenCalled(); + }; + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + expect(mockSession.touch).toHaveBeenCalled(); + }); + + it('sets __session and __client_uat cookie before calling __unstable__onBeforeSetActive', async () => { + mockSession.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + (window as any).__unstable__onBeforeSetActive = () => { + expect(eventBusSpy).toHaveBeenCalledWith('token:update', { token: mockSession.lastActiveToken }); + }; + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + }); + + it('calls __unstable__onAfterSetActive after beforeEmit and session.touch', async () => { + const beforeEmitMock = jest.fn(); + mockSession.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + (window as any).__unstable__onAfterSetActive = () => { expect(mockSession.touch).toHaveBeenCalled(); - }); + expect(beforeEmitMock).toHaveBeenCalled(); + }; - it('does not call session.touch if Clerk was initialised with touchSession set to false', async () => { - mockSession.touch.mockReturnValueOnce(Promise.resolve()); - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); - mockSession.getToken.mockResolvedValue('mocked-token'); + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource, beforeEmit: beforeEmitMock }); + }); - const sut = new Clerk(productionPublishableKey); - await sut.load({ touchSession: false }); - await sut.setActive({ session: mockSession as any as ActiveSessionResource }); - await waitFor(() => { - expect(mockSession.touch).not.toHaveBeenCalled(); - expect(mockSession.getToken).toHaveBeenCalled(); - }); + // TODO: @dimkl include set transitive state + it('calls session.touch -> set cookie -> before emit with touched session on session switch', async () => { + const mockSession2 = { + id: '2', + remove: jest.fn(), + status: 'active', + user: {}, + touch: jest.fn(), + getToken: jest.fn(), + }; + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession, mockSession2], + }), + ); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + const executionOrder: string[] = []; + mockSession2.touch.mockImplementationOnce(() => { + sut.session = mockSession2 as any; + executionOrder.push('session.touch'); + return Promise.resolve(); + }); + mockSession2.getToken.mockImplementation(() => { + executionOrder.push('set cookie'); + return 'mocked-token-2'; + }); + const beforeEmitMock = jest.fn().mockImplementationOnce(() => { + executionOrder.push('before emit'); + return Promise.resolve(); }); - it('calls __unstable__onBeforeSetActive before session.touch', async () => { - mockSession.touch.mockReturnValueOnce(Promise.resolve()); - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + await sut.setActive({ session: mockSession2 as any as ActiveSessionResource, beforeEmit: beforeEmitMock }); - (window as any).__unstable__onBeforeSetActive = () => { - expect(mockSession.touch).not.toHaveBeenCalled(); - }; + await waitFor(() => { + expect(executionOrder).toEqual(['session.touch', 'set cookie', 'before emit']); + expect(mockSession2.touch).toHaveBeenCalled(); + expect(mockSession2.getToken).toHaveBeenCalled(); + expect(beforeEmitMock).toHaveBeenCalledWith(mockSession2); + expect(sut.session).toMatchObject(mockSession2); + }); + }); - const sut = new Clerk(productionPublishableKey); - await sut.load(); - await sut.setActive({ session: mockSession as any as ActiveSessionResource }); - expect(mockSession.touch).toHaveBeenCalled(); + // TODO: @dimkl include set transitive state + it('calls with lastActiveOrganizationId session.touch -> set cookie -> before emit -> set accessors with touched session on organization switch', async () => { + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + const executionOrder: string[] = []; + mockSession.touch.mockImplementationOnce(() => { + sut.session = mockSession as any; + executionOrder.push('session.touch'); + return Promise.resolve(); + }); + mockSession.getToken.mockImplementation(() => { + executionOrder.push('set cookie'); + return 'mocked-token'; }); - it('sets __session and __client_uat cookie before calling __unstable__onBeforeSetActive', async () => { - mockSession.touch.mockReturnValueOnce(Promise.resolve()); - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + const beforeEmitMock = jest.fn().mockImplementationOnce(() => { + executionOrder.push('before emit'); + return Promise.resolve(); + }); - (window as any).__unstable__onBeforeSetActive = () => { - expect(eventBusSpy).toHaveBeenCalledWith('token:update', { token: mockSession.lastActiveToken }); - }; + await sut.setActive({ organization: { id: 'org_id' } as Organization, beforeEmit: beforeEmitMock }); - const sut = new Clerk(productionPublishableKey); - await sut.load(); - await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + await waitFor(() => { + expect(executionOrder).toEqual(['session.touch', 'set cookie', 'before emit']); + expect(mockSession.touch).toHaveBeenCalled(); + expect(mockSession.getToken).toHaveBeenCalled(); + expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); + expect(beforeEmitMock).toHaveBeenCalledWith(mockSession); + expect(sut.session).toMatchObject(mockSession); }); + }); - it('calls __unstable__onAfterSetActive after beforeEmit and session.touch', async () => { - const beforeEmitMock = jest.fn(); - mockSession.touch.mockReturnValueOnce(Promise.resolve()); - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + it('sets active organization by slug', async () => { + const mockSession2 = { + id: '1', + status, + user: { + organizationMemberships: [ + { + id: 'orgmem_id', + organization: { + id: 'org_id', + slug: 'some-org-slug', + }, + }, + ], + }, + touch: jest.fn(), + getToken: jest.fn(), + }; + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession2] })); + const sut = new Clerk(productionPublishableKey); + await sut.load(); - (window as any).__unstable__onAfterSetActive = () => { - expect(mockSession.touch).toHaveBeenCalled(); - expect(beforeEmitMock).toHaveBeenCalled(); - }; + mockSession2.touch.mockImplementationOnce(() => { + sut.session = mockSession2 as any; + return Promise.resolve(); + }); + mockSession2.getToken.mockImplementation(() => 'mocked-token'); - const sut = new Clerk(productionPublishableKey); - await sut.load(); - await sut.setActive({ session: mockSession as any as ActiveSessionResource, beforeEmit: beforeEmitMock }); + await sut.setActive({ organization: 'some-org-slug' }); + + await waitFor(() => { + expect(mockSession2.touch).toHaveBeenCalled(); + expect(mockSession2.getToken).toHaveBeenCalled(); + expect((mockSession2 as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); + expect(sut.session).toMatchObject(mockSession2); }); + }); - // TODO: @dimkl include set transitive state - it('calls session.touch -> set cookie -> before emit with touched session on session switch', async () => { - const mockSession2 = { - id: '2', - remove: jest.fn(), - status: 'active', - user: {}, - touch: jest.fn(), - getToken: jest.fn(), - }; - mockClientFetch.mockReturnValue( - Promise.resolve({ - signedInSessions: [mockSession, mockSession2], - }), - ); + it('redirects the user to the /v1/client/touch endpoint if the cookie_expires_at is less than 8 days away', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession], + cookieExpiresAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now + isEligibleForTouch: () => true, + buildTouchUrl: () => + `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, + }), + ); - const sut = new Clerk(productionPublishableKey); - await sut.load(); + const sut = new Clerk(productionPublishableKey); + sut.navigate = jest.fn(); + await sut.load(); + await sut.setActive({ + session: mockSession as any as ActiveSessionResource, + redirectUrl: '/redirect-url-path', + }); + const redirectUrl = new URL((sut.navigate as jest.Mock).mock.calls[0]); + expect(redirectUrl.pathname).toEqual('/v1/client/touch'); + expect(redirectUrl.searchParams.get('redirect_url')).toEqual(`${mockWindowLocation.href}/redirect-url-path`); + }); - const executionOrder: string[] = []; - mockSession2.touch.mockImplementationOnce(() => { - sut.session = mockSession2 as any; - executionOrder.push('session.touch'); - return Promise.resolve(); - }); - mockSession2.getToken.mockImplementation(() => { - executionOrder.push('set cookie'); - return 'mocked-token-2'; - }); - const beforeEmitMock = jest.fn().mockImplementationOnce(() => { - executionOrder.push('before emit'); - return Promise.resolve(); - }); + it('does not redirect the user to the /v1/client/touch endpoint if the cookie_expires_at is more than 8 days away', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession], + cookieExpiresAt: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10 days from now + isEligibleForTouch: () => false, + buildTouchUrl: () => + `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, + }), + ); - await sut.setActive({ session: mockSession2 as any as ActiveSessionResource, beforeEmit: beforeEmitMock }); + const sut = new Clerk(productionPublishableKey); + sut.navigate = jest.fn(); + await sut.load(); + await sut.setActive({ + session: mockSession as any as ActiveSessionResource, + redirectUrl: '/redirect-url-path', + }); + expect(sut.navigate).toHaveBeenCalledWith('/redirect-url-path'); + }); - await waitFor(() => { - expect(executionOrder).toEqual(['session.touch', 'set cookie', 'before emit']); - expect(mockSession2.touch).toHaveBeenCalled(); - expect(mockSession2.getToken).toHaveBeenCalled(); - expect(beforeEmitMock).toHaveBeenCalledWith(mockSession2); - expect(sut.session).toMatchObject(mockSession2); - }); + it('does not redirect the user to the /v1/client/touch endpoint if the cookie_expires_at is not set', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession], + cookieExpiresAt: null, + isEligibleForTouch: () => false, + buildTouchUrl: () => + `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, + }), + ); + + const sut = new Clerk(productionPublishableKey); + sut.navigate = jest.fn(); + await sut.load(); + await sut.setActive({ + session: mockSession as any as ActiveSessionResource, + redirectUrl: '/redirect-url-path', }); + expect(sut.navigate).toHaveBeenCalledWith('/redirect-url-path'); + }); - // TODO: @dimkl include set transitive state - it('calls with lastActiveOrganizationId session.touch -> set cookie -> before emit -> set accessors with touched session on organization switch', async () => { + mockNativeRuntime(() => { + it('calls session.touch in a non-standard browser', async () => { mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + const sut = new Clerk(productionPublishableKey); - await sut.load(); + await sut.load({ standardBrowser: false }); const executionOrder: string[] = []; mockSession.touch.mockImplementationOnce(() => { @@ -319,11 +458,6 @@ describe('Clerk singleton', () => { executionOrder.push('session.touch'); return Promise.resolve(); }); - mockSession.getToken.mockImplementation(() => { - executionOrder.push('set cookie'); - return 'mocked-token'; - }); - const beforeEmitMock = jest.fn().mockImplementationOnce(() => { executionOrder.push('before emit'); return Promise.resolve(); @@ -331,152 +465,98 @@ describe('Clerk singleton', () => { await sut.setActive({ organization: { id: 'org_id' } as Organization, beforeEmit: beforeEmitMock }); - await waitFor(() => { - expect(executionOrder).toEqual(['session.touch', 'set cookie', 'before emit']); - expect(mockSession.touch).toHaveBeenCalled(); - expect(mockSession.getToken).toHaveBeenCalled(); - expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); - expect(beforeEmitMock).toHaveBeenCalledWith(mockSession); - expect(sut.session).toMatchObject(mockSession); - }); + expect(executionOrder).toEqual(['session.touch', 'before emit']); + expect(mockSession.touch).toHaveBeenCalled(); + expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); + expect(mockSession.getToken).toHaveBeenCalled(); + expect(beforeEmitMock).toHaveBeenCalledWith(mockSession); + expect(sut.session).toMatchObject(mockSession); }); + }); + }); - it('sets active organization by slug', async () => { - const mockSession2 = { - id: '1', - status, - user: { - organizationMemberships: [ - { - id: 'orgmem_id', - organization: { - id: 'org_id', - slug: 'some-org-slug', - }, - }, - ], - }, - touch: jest.fn(), - getToken: jest.fn(), - }; - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession2] })); - const sut = new Clerk(productionPublishableKey); - await sut.load(); + describe('with `pending` session status', () => { + const mockSession = { + id: '1', + remove: jest.fn(), + status: 'pending', + user: {}, + touch: jest.fn(() => Promise.resolve()), + getToken: jest.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + tasks: [{ key: 'org' }], + currentTask: { key: 'org', __internal_getUrl: () => 'https://foocorp.com/add-organization' }, + }; + let eventBusSpy; - mockSession2.touch.mockImplementationOnce(() => { - sut.session = mockSession2 as any; - return Promise.resolve(); - }); - mockSession2.getToken.mockImplementation(() => 'mocked-token'); + beforeEach(() => { + eventBusSpy = jest.spyOn(eventBus, 'dispatch'); + }); - await sut.setActive({ organization: 'some-org-slug' }); + afterEach(() => { + mockSession.remove.mockReset(); + mockSession.touch.mockReset(); - await waitFor(() => { - expect(mockSession2.touch).toHaveBeenCalled(); - expect(mockSession2.getToken).toHaveBeenCalled(); - expect((mockSession2 as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); - expect(sut.session).toMatchObject(mockSession2); - }); - }); + eventBusSpy?.mockRestore(); + // cleanup global window pollution + (window as any).__unstable__onBeforeSetActive = null; + (window as any).__unstable__onAfterSetActive = null; + }); - it('redirects the user to the /v1/client/touch endpoint if the cookie_expires_at is less than 8 days away', async () => { - mockSession.touch.mockReturnValue(Promise.resolve()); - mockClientFetch.mockReturnValue( - Promise.resolve({ - signedInSessions: [mockSession], - cookieExpiresAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now - isEligibleForTouch: () => true, - buildTouchUrl: () => - `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, - }), - ); + it('calls session.touch by default', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); - const sut = new Clerk(productionPublishableKey); - sut.navigate = jest.fn(); - await sut.load(); - await sut.setActive({ - session: mockSession as any as ActiveSessionResource, - redirectUrl: '/redirect-url-path', - }); - const redirectUrl = new URL((sut.navigate as jest.Mock).mock.calls[0]); - expect(redirectUrl.pathname).toEqual('/v1/client/touch'); - expect(redirectUrl.searchParams.get('redirect_url')).toEqual(`${mockWindowLocation.href}/redirect-url-path`); - }); + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + expect(mockSession.touch).toHaveBeenCalled(); + }); - it('does not redirect the user to the /v1/client/touch endpoint if the cookie_expires_at is more than 8 days away', async () => { - mockSession.touch.mockReturnValue(Promise.resolve()); - mockClientFetch.mockReturnValue( - Promise.resolve({ - signedInSessions: [mockSession], - cookieExpiresAt: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10 days from now - isEligibleForTouch: () => false, - buildTouchUrl: () => - `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, - }), - ); + it('does not call session.touch if Clerk was initialised with touchSession set to false', async () => { + mockSession.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + mockSession.getToken.mockResolvedValue('mocked-token'); - const sut = new Clerk(productionPublishableKey); - sut.navigate = jest.fn(); - await sut.load(); - await sut.setActive({ - session: mockSession as any as ActiveSessionResource, - redirectUrl: '/redirect-url-path', - }); - expect(sut.navigate).toHaveBeenCalledWith('/redirect-url-path'); + const sut = new Clerk(productionPublishableKey); + await sut.load({ touchSession: false }); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + await waitFor(() => { + expect(mockSession.touch).not.toHaveBeenCalled(); + expect(mockSession.getToken).toHaveBeenCalled(); }); + }); - it('does not redirect the user to the /v1/client/touch endpoint if the cookie_expires_at is not set', async () => { - mockSession.touch.mockReturnValue(Promise.resolve()); - mockClientFetch.mockReturnValue( - Promise.resolve({ - signedInSessions: [mockSession], - cookieExpiresAt: null, - isEligibleForTouch: () => false, - buildTouchUrl: () => - `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, - }), - ); + it('navigates to task and set accessors with touched session', async () => { + mockClientFetch.mockReturnValue(Promise.resolve({ sessions: [mockSession], signedInSessions: [mockSession] })); + const sut = new Clerk(productionPublishableKey); + await sut.load(); - const sut = new Clerk(productionPublishableKey); - sut.navigate = jest.fn(); - await sut.load(); - await sut.setActive({ - session: mockSession as any as ActiveSessionResource, - redirectUrl: '/redirect-url-path', - }); - expect(sut.navigate).toHaveBeenCalledWith('/redirect-url-path'); + const executionOrder: string[] = []; + mockSession.touch.mockImplementationOnce(() => { + sut.session = mockSession as any; + executionOrder.push('session.touch'); + return Promise.resolve(); + }); + mockSession.getToken.mockImplementation(() => { + executionOrder.push('set cookie'); + return 'mocked-token'; + }); + sut.navigate = jest.fn().mockImplementationOnce(() => { + executionOrder.push('navigate'); + return Promise.resolve(); }); - mockNativeRuntime(() => { - it('calls session.touch in a non-standard browser', async () => { - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); - - const sut = new Clerk(productionPublishableKey); - await sut.load({ standardBrowser: false }); - - const executionOrder: string[] = []; - mockSession.touch.mockImplementationOnce(() => { - sut.session = mockSession as any; - executionOrder.push('session.touch'); - return Promise.resolve(); - }); - const beforeEmitMock = jest.fn().mockImplementationOnce(() => { - executionOrder.push('before emit'); - return Promise.resolve(); - }); - - await sut.setActive({ organization: { id: 'org_id' } as Organization, beforeEmit: beforeEmitMock }); - - expect(executionOrder).toEqual(['session.touch', 'before emit']); - expect(mockSession.touch).toHaveBeenCalled(); - expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); - expect(mockSession.getToken).toHaveBeenCalled(); - expect(beforeEmitMock).toHaveBeenCalledWith(mockSession); - expect(sut.session).toMatchObject(mockSession); - }); + await sut.setActive({ session: mockSession.id }); + + await waitFor(() => { + expect(executionOrder).toEqual(['session.touch', 'set cookie', 'navigate']); + expect(mockSession.touch).toHaveBeenCalled(); + expect(mockSession.getToken).toHaveBeenCalled(); + expect(sut.session).toMatchObject(mockSession); }); - }, - ); + }); + }); }); describe('.load()', () => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index a0c54ac931a..ec004989fb7 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -44,7 +44,6 @@ import type { OrganizationProfileProps, OrganizationResource, OrganizationSwitcherProps, - PendingSessionResource, PublicKeyCredentialCreationOptionsWithoutExtensions, PublicKeyCredentialRequestOptionsWithoutExtensions, PublicKeyCredentialWithAuthenticatorAssertionResponse, @@ -132,6 +131,7 @@ import { Organization, Waitlist, } from './resources/internal'; +import { navigateToTask } from './sessionTasks'; import { warnings } from './warnings'; type SetActiveHook = (intent?: 'sign-out') => void | Promise<void>; @@ -956,13 +956,13 @@ export class Clerk implements ClerkInterface { session = (this.client.sessions.find(x => x.id === session) as SignedInSessionResource) || null; } - let newSession = session === undefined ? this.session : session; - - if (newSession?.status === 'pending') { - await this.#handlePendingSession(newSession); + if (session?.status === 'pending') { + await this.#handlePendingSession(session); return; } + let newSession = session === undefined ? this.session : session; + // At this point, the `session` variable should contain either an `SignedInSessionResource` // ,`null` or `undefined`. // We now want to set the last active organization id on that session (if it exists). @@ -1050,21 +1050,38 @@ export class Clerk implements ClerkInterface { await onAfterSetActive(); }; - #handlePendingSession = async (session: PendingSessionResource) => { - if (!session.currentTask || !this.environment) { + #handlePendingSession = async (session: SignedInSessionResource) => { + if (!this.environment) { return; } - if (session?.lastActiveToken) { + if (session.lastActiveToken) { eventBus.dispatch(events.TokenUpdate, { token: session.lastActiveToken }); } - if (this.#internalComponentNavigate) { - // Handles navigation for UI components - await this.#internalComponentNavigate(session.currentTask.__internal_getPath()); - } else { - // Handles navigation for custom flows - await this.navigate(session.currentTask.__internal_getUrl(this.#options, this.environment)); + // Handles multi-session scenario when switching from `active` + // to `pending` + if (inActiveBrowserTab() || !this.#options.standardBrowser) { + await this.#touchCurrentSession(session); + session = this.#getSessionFromClient(session.id) ?? session; + } + + // Syncs __session and __client_uat, in case the `pending` session + // has expired, it needs to trigger a sign-out + const token = await session.getToken(); + if (!token) { + eventBus.dispatch(events.TokenUpdate, { token: null }); + } + + if (session.currentTask) { + await navigateToTask(session.currentTask, { + isInternalNavigation: !!this.#internalComponentNavigate, + navigate: this.#internalComponentNavigate ?? this.navigate, + options: this.#options, + environment: this.environment, + }); + + this.#internalComponentNavigate = null; } this.#setAccessors(session); @@ -1098,12 +1115,8 @@ export class Clerk implements ClerkInterface { return unsubscribe; }; - public __internal_setComponentNavigate = (navigate: (to: string) => Promise<unknown>): UnsubscribeCallback => { + public __internal_setComponentNavigate = (navigate: (to: string) => Promise<unknown>) => { this.#internalComponentNavigate = navigate; - const unsubscribe = () => { - this.#internalComponentNavigate = null; - }; - return unsubscribe; }; public navigate = async (to: string | undefined, options?: NavigateOptions): Promise<unknown> => { diff --git a/packages/clerk-js/src/core/events.ts b/packages/clerk-js/src/core/events.ts index 4982ed35da0..7401dd91370 100644 --- a/packages/clerk-js/src/core/events.ts +++ b/packages/clerk-js/src/core/events.ts @@ -1,21 +1,18 @@ -import type { SessionResource, TokenResource } from '@clerk/types'; +import type { TokenResource } from '@clerk/types'; export const events = { TokenUpdate: 'token:update', UserSignOut: 'user:signOut', - InternalComponentNavigate: 'task:internalNavigate', } as const; type ClerkEvent = (typeof events)[keyof typeof events]; type EventHandler<E extends ClerkEvent> = (payload: EventPayload[E]) => void; type TokenUpdatePayload = { token: TokenResource | null }; -type InternalComponentNavigatePayload = { resolveNavigation: () => void; session: SessionResource }; type EventPayload = { [events.TokenUpdate]: TokenUpdatePayload; [events.UserSignOut]: null; - [events.InternalComponentNavigate]: InternalComponentNavigatePayload; }; const createEventBus = () => { @@ -48,11 +45,7 @@ const createEventBus = () => { eventToHandlersMap.set(event, []); }; - const has = <E extends ClerkEvent>(event: E) => { - return !!eventToHandlersMap.has(event); - }; - - return { on, dispatch, off, has }; + return { on, dispatch, off }; }; export const eventBus = createEventBus(); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 5f530d76789..1c9aa745b69 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -13,6 +13,7 @@ import type { SessionJSONSnapshot, SessionResource, SessionStatus, + SessionTask, SessionVerificationJSON, SessionVerificationResource, SessionVerifyAttemptFirstFactorParams, @@ -34,7 +35,6 @@ import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../e import { eventBus, events } from '../events'; import { SessionTokenCache } from '../tokenCache'; import { BaseResource, PublicUserData, Token, User } from './internal'; -import { SessionTask } from './SessionTask'; import { SessionVerification } from './SessionVerification'; export class Session extends BaseResource implements SessionResource { @@ -286,7 +286,7 @@ export class Session extends BaseResource implements SessionResource { this.createdAt = unixEpochToDate(data.created_at); this.updatedAt = unixEpochToDate(data.updated_at); this.user = new User(data.user); - this.tasks = data.tasks?.map(task => new SessionTask(task)) ?? []; + this.tasks = data.tasks; if (data.public_user_data) { this.publicUserData = new PublicUserData(data.public_user_data); diff --git a/packages/clerk-js/src/core/resources/SessionTask.ts b/packages/clerk-js/src/core/resources/SessionTask.ts deleted file mode 100644 index af4760ba012..00000000000 --- a/packages/clerk-js/src/core/resources/SessionTask.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { - ClerkOptions, - EnvironmentResource, - SessionTaskJSON, - SessionTaskJSONSnapshot, - SessionTaskKey, - SessionTaskResource, -} from '@clerk/types'; - -import { buildURL, inBrowser } from '../../utils'; - -export const SESSION_TASK_PATHS = ['add-organization'] as const; -type SessionTaskPath = (typeof SESSION_TASK_PATHS)[number]; - -export const SESSION_TASK_PATH_BY_KEY: Record<SessionTaskKey, SessionTaskPath> = { - org: 'add-organization', -} as const; - -export class SessionTask implements SessionTaskResource { - key!: SessionTaskKey; - - constructor(data: SessionTaskJSON | SessionTaskJSONSnapshot) { - this.fromJSON(data); - } - - protected fromJSON(data: SessionTaskJSON | SessionTaskJSONSnapshot): this { - if (!data) { - return this; - } - - this.key = data.key; - - return this; - } - - public __internal_toSnapshot(): SessionTaskJSONSnapshot { - return { - key: this.key, - }; - } - - public __internal_getUrlPath(): `/${SessionTaskPath}` { - return `/${SESSION_TASK_PATH_BY_KEY[this.key]}`; - } - - public __internal_getAbsoluteUrl(options: ClerkOptions, environment?: EnvironmentResource | null): string { - if (!environment || !inBrowser()) { - return ''; - } - - const signInUrl = options['signInUrl'] || environment.displayConfig.signInUrl; - const signUpUrl = options['signUpUrl'] || environment.displayConfig.signUpUrl; - const isReferrerSignUpUrl = window.location.href.startsWith(signUpUrl); - - return buildURL( - // TODO - Introduce custom `tasksUrl` option to be used as a base path fallback for custom flows - { base: isReferrerSignUpUrl ? signUpUrl : signInUrl, hashPath: this.__internal_getUrlPath() }, - { stringify: true }, - ); - } -} diff --git a/packages/clerk-js/src/core/sessionTasks.ts b/packages/clerk-js/src/core/sessionTasks.ts new file mode 100644 index 00000000000..ff39cdbd5db --- /dev/null +++ b/packages/clerk-js/src/core/sessionTasks.ts @@ -0,0 +1,46 @@ +import type { ClerkOptions, EnvironmentResource, SessionTask } from '@clerk/types'; + +import { buildURL } from '../utils'; + +export const SESSION_TASK_ROUTE_BY_KEY: Record<SessionTask['key'], string> = { + org: '/add-organization', +} as const; + +function buildTasksUrl(task: SessionTask, options: ClerkOptions, environment: EnvironmentResource): string { + const signInUrl = options['signInUrl'] || environment.displayConfig.signInUrl; + const signUpUrl = options['signUpUrl'] || environment.displayConfig.signUpUrl; + const isReferrerSignUpUrl = window.location.href.startsWith(signUpUrl); + + return buildURL( + // TODO - Accept custom URL option for custom flows in order to eject out of `signInUrl/signUpUrl` + { + base: isReferrerSignUpUrl ? signUpUrl : signInUrl, + hashPath: SESSION_TASK_ROUTE_BY_KEY[task.key], + }, + { stringify: true }, + ); +} + +interface NavigateToTaskOptions { + isInternalNavigation: boolean; + navigate: (to: string) => Promise<unknown>; + options: ClerkOptions; + environment: EnvironmentResource; +} + +/** + * Initiates navigation to the tasks URL based on the application context such + * as internal component routing or custom flows. + * @internal + */ +export function navigateToTask( + task: SessionTask, + { isInternalNavigation, navigate, options, environment }: NavigateToTaskOptions, +) { + if (!isInternalNavigation) { + // Handles navigation for custom flows, which is triggered outside of UI components routing context + return navigate(buildTasksUrl(task, options, environment)); + } else { + return navigate(SESSION_TASK_ROUTE_BY_KEY['org']); + } +} diff --git a/packages/clerk-js/src/core/warnings.ts b/packages/clerk-js/src/core/warnings.ts index a3eefbcd9f8..1fd500a1e91 100644 --- a/packages/clerk-js/src/core/warnings.ts +++ b/packages/clerk-js/src/core/warnings.ts @@ -21,10 +21,6 @@ const warnings = { cannotRenderComponentWhenUserDoesNotExist: '<UserProfile/> cannot render unless a user is signed in. Since no user is signed in, this is no-op.', cannotRenderComponentWhenOrgDoesNotExist: `<OrganizationProfile/> cannot render unless an organization is active. Since no organization is currently active, this is no-op.`, - cannotRenderSessionTaskComponentOnSignIn: - 'Cannot render component unless a session task exists. Clerk is redirecting to `signInUrl` instead.', - cannotRenderSessionTaskComponentOnSignUp: - 'Cannot render component unless a session task exists. Clerk is redirecting to `signUpUrl` instead.', cannotRenderAnyOrganizationComponent: createMessageForDisabledOrganizations, cannotOpenUserProfile: 'The UserProfile modal cannot render unless a user is signed in. Since no user is signed in, this is no-op.', diff --git a/packages/clerk-js/src/ui/common/redirects.ts b/packages/clerk-js/src/ui/common/redirects.ts index 655e5fcefce..add5954cfeb 100644 --- a/packages/clerk-js/src/ui/common/redirects.ts +++ b/packages/clerk-js/src/ui/common/redirects.ts @@ -1,3 +1,6 @@ +import type { SessionTask } from '@clerk/types'; + +import { SESSION_TASK_ROUTE_BY_KEY } from '../../core/sessionTasks'; import { buildURL } from '../../utils/url'; import type { SignInContextType, SignUpContextType, UserProfileContextType } from './../contexts'; @@ -25,6 +28,22 @@ export function buildVerificationRedirectUrl({ }); } +export function buildSessionTaskRedirectUrl( + ctx: Pick<SignInContextType | SignUpContextType, 'routing' | 'path'>, + baseUrl: string, + task: SessionTask, +) { + const { routing, path } = ctx; + + return buildRedirectUrl({ + routing, + baseUrl, + path, + endpoint: SESSION_TASK_ROUTE_BY_KEY[task.key], + authQueryString: null, + }); +} + export function buildSSOCallbackURL( ctx: Partial<SignInContextType | SignUpContextType>, baseUrl: string | undefined = '', diff --git a/packages/clerk-js/src/ui/common/withRedirect.tsx b/packages/clerk-js/src/ui/common/withRedirect.tsx index 85d73efb3e6..b1d77dcd759 100644 --- a/packages/clerk-js/src/ui/common/withRedirect.tsx +++ b/packages/clerk-js/src/ui/common/withRedirect.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { warnings } from '../../core/warnings'; import type { ComponentGuard } from '../../utils'; -import { noTaskExists, sessionExistsAndSingleSessionModeEnabled } from '../../utils'; +import { sessionExistsAndSingleSessionModeEnabled } from '../../utils'; import { useEnvironment, useOptions, useSignInContext, useSignUpContext } from '../contexts'; import { useRouter } from '../router'; import type { AvailableComponentProps } from '../types'; @@ -61,7 +61,7 @@ export const withRedirectToAfterSignIn = <P extends AvailableComponentProps>(Com return withRedirect( Component, sessionExistsAndSingleSessionModeEnabled, - ({ clerk }) => signInCtx.tasksUrl || signInCtx.afterSignInUrl || clerk.buildAfterSignInUrl(), + ({ clerk }) => signInCtx.taskUrl || signInCtx.afterSignInUrl || clerk.buildAfterSignInUrl(), warnings.cannotRenderSignInComponentWhenSessionExists, )(props); }; @@ -80,7 +80,7 @@ export const withRedirectToAfterSignUp = <P extends AvailableComponentProps>(Com return withRedirect( Component, sessionExistsAndSingleSessionModeEnabled, - ({ clerk }) => signUpCtx.tasksUrl || signUpCtx.afterSignUpUrl || clerk.buildAfterSignUpUrl(), + ({ clerk }) => signUpCtx.taskUrl || signUpCtx.afterSignUpUrl || clerk.buildAfterSignUpUrl(), warnings.cannotRenderSignUpComponentWhenSessionExists, )(props); }; @@ -89,45 +89,3 @@ export const withRedirectToAfterSignUp = <P extends AvailableComponentProps>(Com return HOC; }; - -export const withRedirectToSignUpIfNoTasksAvailable = <P extends AvailableComponentProps>( - Component: ComponentType<P>, -) => { - const displayName = Component.displayName || Component.name || 'Component'; - Component.displayName = displayName; - - const HOC = (props: P) => { - const signUpCtx = useSignUpContext(); - return withRedirect( - Component, - noTaskExists, - ({ clerk }) => signUpCtx.signUpUrl || clerk.buildSignUpUrl(), - warnings.cannotRenderSessionTaskComponentOnSignUp, - )(props); - }; - - HOC.displayName = `withRedirectToSignUpIfNoTasksAvailable(${displayName})`; - - return HOC; -}; - -export const withRedirectToSignInIfNoTasksAvailable = <P extends AvailableComponentProps>( - Component: ComponentType<P>, -) => { - const displayName = Component.displayName || Component.name || 'Component'; - Component.displayName = displayName; - - const HOC = (props: P) => { - const signInCtx = useSignInContext(); - return withRedirect( - Component, - noTaskExists, - ({ clerk }) => signInCtx.signInUrl || clerk.buildSignInUrl(), - warnings.cannotRenderSessionTaskComponentOnSignUp, - )(props); - }; - - HOC.displayName = `withRedirectToSignInIfNoTasksAvailable(${displayName})`; - - return HOC; -}; diff --git a/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx b/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx index 1a74f54e1cd..0f0cf55fa6e 100644 --- a/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx +++ b/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx @@ -1,5 +1,4 @@ -import { useSessionContext } from '@clerk/shared/react/index'; -import type { SessionTaskKey } from '@clerk/types'; +import type { SessionTask } from '@clerk/types'; import { type ComponentType } from 'react'; import { OrganizationListContext } from '../../contexts'; @@ -8,7 +7,7 @@ import { OrganizationList } from '../OrganizationList'; /** * @internal */ -const SessionTaskRegistry: Record<SessionTaskKey, ComponentType> = { +const SessionTaskRegistry: Record<SessionTask['key'], ComponentType> = { org: () => ( // TODO - Hide personal workspace within organization list context based on environment <OrganizationListContext.Provider value={{ componentName: 'OrganizationList', hidePersonal: true }}> @@ -20,15 +19,7 @@ const SessionTaskRegistry: Record<SessionTaskKey, ComponentType> = { /** * @internal */ -export function SessionTask(): React.ReactNode { - const session = useSessionContext(); - const [currentTask] = session?.tasks ?? []; - - if (!currentTask) { - return null; - } - - const Content = SessionTaskRegistry[currentTask.key]; - - return Content ? <Content /> : null; +export function SessionTask({ task }: { task: SessionTask['key'] }): React.ReactNode { + const Content = SessionTaskRegistry[task]; + return <Content />; } diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index e5c5a5263e5..b04615ebbb6 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -2,9 +2,7 @@ import { useClerk } from '@clerk/shared/react'; import type { SignInModalProps, SignInProps } from '@clerk/types'; import React from 'react'; -import { SESSION_TASK_PATHS, SessionTask } from '../../../core/resources/SessionTask'; import { normalizeRoutingOptions } from '../../../utils/normalizeRoutingOptions'; -import { withRedirectToSignInIfNoTasksAvailable } from '../../common'; import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; import type { SignUpContextType } from '../../contexts'; import { @@ -17,8 +15,10 @@ import { import { Flow } from '../../customizables'; import { useFetch } from '../../hooks'; import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; +import { SessionTask } from '../SessionTask'; import { LazySignUpContinue, + LazySignUpSessionTask, LazySignUpSSOCallback, LazySignUpStart, LazySignUpVerifyEmail, @@ -82,6 +82,7 @@ function SignInRoutes(): JSX.Element { redirectUrl='../factor-two' /> </Route> + {signInContext.isCombinedFlow && ( <Route path='create'> <Route @@ -129,43 +130,21 @@ function SignInRoutes(): JSX.Element { > <LazySignUpVerifyPhone /> </Route> - {SESSION_TASK_PATHS.map(path => ( - <Route - path={path} - key={path} - > - <SignInSessionTask /> - </Route> - ))} + <Route path='add-organization'> + <LazySignUpSessionTask task='org' /> + </Route> <Route index> <LazySignUpContinue /> </Route> </Route> - - {SESSION_TASK_PATHS.map(path => ( - <Route - path={path} - key={path} - > - <SignInSessionTask /> - </Route> - ))} - <Route index> <LazySignUpStart /> </Route> </Route> )} - - {SESSION_TASK_PATHS.map(path => ( - <Route - path={path} - key={path} - > - <SignInSessionTask /> - </Route> - ))} - + <Route path='add-organization'> + <SessionTask task='org' /> + </Route> <Route index> <SignInStart /> </Route> @@ -237,5 +216,3 @@ export const SignInModal = (props: SignInModalProps): JSX.Element => { </Route> ); }; - -const SignInSessionTask = withRedirectToSignInIfNoTasksAvailable(SessionTask); diff --git a/packages/clerk-js/src/ui/components/SignIn/lazy-sign-up.ts b/packages/clerk-js/src/ui/components/SignIn/lazy-sign-up.ts index e3c08113fd9..1101083cab1 100644 --- a/packages/clerk-js/src/ui/components/SignIn/lazy-sign-up.ts +++ b/packages/clerk-js/src/ui/components/SignIn/lazy-sign-up.ts @@ -7,6 +7,7 @@ const LazySignUpVerifyEmail = lazy(() => preloadSignUp().then(m => ({ default: m const LazySignUpStart = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpStart }))); const LazySignUpSSOCallback = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpSSOCallback }))); const LazySignUpContinue = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpContinue }))); +const LazySignUpSessionTask = lazy(() => preloadSignUp().then(m => ({ default: m.SessionTask }))); const lazyCompleteSignUpFlow = () => import(/* webpackChunkName: "signUp" */ '../SignUp/util').then(m => m.completeSignUpFlow); @@ -19,4 +20,5 @@ export { LazySignUpSSOCallback, LazySignUpContinue, lazyCompleteSignUpFlow, + LazySignUpSessionTask, }; diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx index 12ca62f4948..04478446b76 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx @@ -2,8 +2,6 @@ import { useClerk } from '@clerk/shared/react'; import type { SignUpModalProps, SignUpProps } from '@clerk/types'; import React from 'react'; -import { SESSION_TASK_PATHS } from '../../../core/resources/SessionTask'; -import { withRedirectToSignUpIfNoTasksAvailable } from '../../../ui/common'; import { SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; import { SignUpContext, useSignUpContext, withCoreSessionSwitchGuard } from '../../contexts'; import { Flow } from '../../customizables'; @@ -77,14 +75,9 @@ function SignUpRoutes(): JSX.Element { <SignUpContinue /> </Route> </Route> - {SESSION_TASK_PATHS.map(path => ( - <Route - path={path} - key={path} - > - <SignUpSessionTask /> - </Route> - ))} + <Route path='add-organization'> + <SessionTask task='org' /> + </Route> <Route index> <SignUpStart /> </Route> @@ -129,6 +122,4 @@ export const SignUpModal = (props: SignUpModalProps): JSX.Element => { ); }; -const SignUpSessionTask = withRedirectToSignUpIfNoTasksAvailable(SessionTask); - -export { SignUpContinue, SignUpSSOCallback, SignUpStart, SignUpVerifyEmail, SignUpVerifyPhone }; +export { SignUpContinue, SignUpSSOCallback, SignUpStart, SignUpVerifyEmail, SignUpVerifyPhone, SessionTask }; diff --git a/packages/clerk-js/src/ui/contexts/components/SignIn.ts b/packages/clerk-js/src/ui/contexts/components/SignIn.ts index 48018f1b574..bcc6aafed84 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignIn.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignIn.ts @@ -1,13 +1,17 @@ import { useClerk } from '@clerk/shared/react'; import { isAbsoluteUrl } from '@clerk/shared/url'; -import { createContext, useContext, useMemo } from 'react'; +import { createContext, useContext, useEffect, useMemo } from 'react'; import { SIGN_IN_INITIAL_VALUE_KEYS } from '../../../core/constants'; import { buildURL } from '../../../utils'; import { RedirectUrls } from '../../../utils/redirectUrls'; -import { buildRedirectUrl, MAGIC_LINK_VERIFY_PATH_ROUTE, SSO_CALLBACK_PATH_ROUTE } from '../../common/redirects'; +import { + buildRedirectUrl, + buildSessionTaskRedirectUrl, + MAGIC_LINK_VERIFY_PATH_ROUTE, + SSO_CALLBACK_PATH_ROUTE, +} from '../../common/redirects'; import { useEnvironment, useOptions } from '../../contexts'; -import { useNavigateOnEvent } from '../../hooks/useNavigateOnEvent'; import type { ParsedQueryString } from '../../router'; import { useRouter } from '../../router'; import type { SignInCtx } from '../../types'; @@ -22,7 +26,7 @@ export type SignInContextType = SignInCtx & { authQueryString: string | null; afterSignUpUrl: string; afterSignInUrl: string; - tasksUrl: string | null; + taskUrl: string | null; transferable: boolean; waitlistUrl: string; emailLinkRedirectUrl: string; @@ -114,21 +118,17 @@ export const useSignInContext = (): SignInContextType => { const signUpContinueUrl = buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true }); - const tasksUrl = clerk.session?.currentTask - ? buildRedirectUrl({ - routing: ctx.routing, - baseUrl: signInUrl, - path: ctx.path, - endpoint: clerk.session?.currentTask?.__internal_getUrlPath(), - authQueryString: null, - }) + const taskUrl = clerk.session?.currentTask + ? buildSessionTaskRedirectUrl({ routing: ctx.routing, path: ctx.path }, signInUrl, clerk.session?.currentTask) : null; - useNavigateOnEvent({ - routing: ctx.routing, - baseUrl: signInUrl, - path: ctx.path, - }); + useEffect(() => { + clerk.__internal_setComponentNavigate((endpoint: string) => + navigate( + buildRedirectUrl({ routing: ctx.routing, path: ctx.path, baseUrl: signInUrl, endpoint, authQueryString }), + ), + ); + }, []); return { ...(ctx as SignInCtx), @@ -141,7 +141,7 @@ export const useSignInContext = (): SignInContextType => { afterSignUpUrl, emailLinkRedirectUrl, ssoCallbackUrl, - tasksUrl, + taskUrl, navigateAfterSignIn, signUpContinueUrl, queryParams, diff --git a/packages/clerk-js/src/ui/contexts/components/SignUp.ts b/packages/clerk-js/src/ui/contexts/components/SignUp.ts index 4239965ecde..44fdbf06542 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignUp.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignUp.ts @@ -1,13 +1,17 @@ import { useClerk } from '@clerk/shared/react'; import { isAbsoluteUrl } from '@clerk/shared/url'; -import { createContext, useContext, useMemo } from 'react'; +import { createContext, useContext, useEffect, useMemo } from 'react'; import { SIGN_UP_INITIAL_VALUE_KEYS } from '../../../core/constants'; import { buildURL } from '../../../utils'; import { RedirectUrls } from '../../../utils/redirectUrls'; -import { buildRedirectUrl, MAGIC_LINK_VERIFY_PATH_ROUTE, SSO_CALLBACK_PATH_ROUTE } from '../../common/redirects'; +import { + buildRedirectUrl, + buildSessionTaskRedirectUrl, + MAGIC_LINK_VERIFY_PATH_ROUTE, + SSO_CALLBACK_PATH_ROUTE, +} from '../../common/redirects'; import { useEnvironment, useOptions } from '../../contexts'; -import { useNavigateOnEvent } from '../../hooks/useNavigateOnEvent'; import type { ParsedQueryString } from '../../router'; import { useRouter } from '../../router'; import type { SignUpCtx } from '../../types'; @@ -23,7 +27,7 @@ export type SignUpContextType = SignUpCtx & { afterSignUpUrl: string; afterSignInUrl: string; waitlistUrl: string; - tasksUrl: string | null; + taskUrl: string | null; isCombinedFlow: boolean; emailLinkRedirectUrl: string; ssoCallbackUrl: string; @@ -109,21 +113,17 @@ export const useSignUpContext = (): SignUpContextType => { // TODO: Avoid building this url again to remove duplicate code. Get it from window.Clerk instead. const secondFactorUrl = buildURL({ base: signInUrl, hashPath: '/factor-two' }, { stringify: true }); - const tasksUrl = clerk.session?.currentTask - ? buildRedirectUrl({ - routing: ctx.routing, - baseUrl: signUpUrl, - path: ctx.path, - endpoint: clerk.session?.currentTask?.__internal_getUrlPath(), - authQueryString: null, - }) + const taskUrl = clerk.session?.currentTask + ? buildSessionTaskRedirectUrl({ routing: ctx.routing, path: ctx.path }, signUpUrl, clerk.session?.currentTask) : null; - useNavigateOnEvent({ - routing: ctx.routing, - baseUrl: signUpUrl, - path: ctx.path, - }); + useEffect(() => { + clerk.__internal_setComponentNavigate((endpoint: string) => + navigate( + buildRedirectUrl({ routing: ctx.routing, path: ctx.path, baseUrl: signUpUrl, endpoint, authQueryString }), + ), + ); + }, []); return { ...ctx, @@ -136,7 +136,7 @@ export const useSignUpContext = (): SignUpContextType => { afterSignInUrl, emailLinkRedirectUrl, ssoCallbackUrl, - tasksUrl, + taskUrl, navigateAfterSignUp, queryParams, initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, diff --git a/packages/clerk-js/src/ui/hooks/useNavigateOnEvent.ts b/packages/clerk-js/src/ui/hooks/useNavigateOnEvent.ts deleted file mode 100644 index 09e1e37f5f1..00000000000 --- a/packages/clerk-js/src/ui/hooks/useNavigateOnEvent.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { SessionResource } from '@clerk/types'; -import { useEffect } from 'react'; - -import { eventBus, events } from '../../core/events'; -import { buildRedirectUrl } from '../common'; -import { useRouter } from '../router'; - -type UseNavigateOnEventOptions = Pick<Parameters<typeof buildRedirectUrl>[0], 'routing' | 'baseUrl' | 'path'>; - -/** - * Custom hook to trigger internal component navigation by a event. - */ -export const useNavigateOnEvent = ({ routing, baseUrl, path }: UseNavigateOnEventOptions) => { - const { navigate } = useRouter(); - - useEffect(() => { - const handleNavigation = ({ - resolveNavigation, - session, - }: { - resolveNavigation: () => void; - session: SessionResource; - }) => { - if (!session.currentTask) { - return; - } - - void navigate( - buildRedirectUrl({ - routing, - baseUrl, - path, - endpoint: session.currentTask.__internal_getUrlPath(), - authQueryString: null, - }), - ).then(resolveNavigation); - }; - - eventBus.on(events.InternalComponentNavigate, handleNavigation); - - return () => { - eventBus.off(events.InternalComponentNavigate, handleNavigation); - }; - }, []); -}; diff --git a/packages/clerk-js/src/utils/componentGuards.ts b/packages/clerk-js/src/utils/componentGuards.ts index 07b1c08e751..f0084a6a47d 100644 --- a/packages/clerk-js/src/utils/componentGuards.ts +++ b/packages/clerk-js/src/utils/componentGuards.ts @@ -14,10 +14,6 @@ export const noUserExists: ComponentGuard = clerk => { return !clerk.user; }; -export const noTaskExists: ComponentGuard = clerk => { - return !clerk.session?.currentTask; -}; - export const noOrganizationExists: ComponentGuard = clerk => { return !clerk.organization; }; diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 455b9a72aa8..7db4b79b573 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -93,6 +93,7 @@ type IsomorphicLoadedClerk = Without< | '__internal_getCachedResources' | '__internal_reloadInitialResources' | '__experimental_commerce' + | '__internal_setComponentNavigate' > & { client: ClientResource | undefined; __experimental_commerce: __experimental_CommerceNamespace | undefined; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 29834688505..f412b2e8a57 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -426,6 +426,13 @@ export interface Clerk { */ __internal_addNavigationListener: (callback: () => void) => UnsubscribeCallback; + /** + * Registers an internal navigate function for UI components in order to be triggered + * from `Clerk` + * @internal + */ + __internal_setComponentNavigate: (navigate: (to: string) => Promise<unknown>) => void; + /** * Set the active session and organization explicitly. * diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index afab7bcf0f6..21f378f0cf3 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -13,7 +13,7 @@ import type { OrganizationCustomRoleKey, OrganizationPermissionKey } from './org import type { OrganizationSettingsJSON } from './organizationSettings'; import type { OrganizationSuggestionStatus } from './organizationSuggestion'; import type { SamlIdpSlug } from './saml'; -import type { SessionStatus, SessionTaskKey } from './session'; +import type { SessionStatus, SessionTask } from './session'; import type { SessionVerificationLevel, SessionVerificationStatus } from './sessionVerification'; import type { SignInFirstFactor, SignInJSON, SignInSecondFactor } from './signIn'; import type { SignUpField, SignUpIdentificationField, SignUpStatus } from './signUp'; @@ -103,10 +103,6 @@ export interface SignUpJSON extends ClerkResourceJSON { verifications: SignUpVerificationsJSON | null; } -export interface SessionTaskJSON { - key: SessionTaskKey; -} - export interface SessionJSON extends ClerkResourceJSON { object: 'session'; id: string; @@ -123,7 +119,7 @@ export interface SessionJSON extends ClerkResourceJSON { last_active_token: TokenJSON; last_active_organization_id: string | null; actor: ActJWTClaim | null; - tasks: Array<SessionTaskJSON> | null; + tasks: Array<SessionTask> | null; user: UserJSON; public_user_data: PublicUserDataJSON; created_at: number; diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index 5b8a92677e6..bd48545b462 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -1,6 +1,3 @@ -import type { ClerkOptions } from 'clerk'; - -import type { EnvironmentResource } from './environment'; import type { BackupCodeAttempt, EmailCodeAttempt, @@ -126,8 +123,8 @@ export interface SessionResource extends ClerkResource { lastActiveOrganizationId: string | null; lastActiveAt: Date; actor: ActJWTClaim | null; - tasks: Array<SessionTaskResource> | null; - currentTask?: SessionTaskResource; + tasks: Array<SessionTask> | null; + currentTask?: SessionTask; /** * The user associated with the session. */ @@ -177,6 +174,7 @@ export interface ActiveSessionResource extends SessionResource { export interface PendingSessionResource extends SessionResource { status: 'pending'; user: UserResource; + currentTask: SessionTask; } /** @@ -227,12 +225,8 @@ export interface PublicUserData { userId?: string; } -export type SessionTaskKey = 'org'; - -export interface SessionTaskResource { - key: SessionTaskKey; - __internal_getUrlPath: () => string; - __internal_getAbsoluteUrl: (options: ClerkOptions, environment?: EnvironmentResource | null) => string; +export interface SessionTask { + key: 'org'; } export type GetTokenOptions = { diff --git a/packages/types/src/snapshots.ts b/packages/types/src/snapshots.ts index babd482803c..bc398bb633e 100644 --- a/packages/types/src/snapshots.ts +++ b/packages/types/src/snapshots.ts @@ -19,7 +19,6 @@ import type { SamlAccountConnectionJSON, SamlAccountJSON, SessionJSON, - SessionTaskJSON, SignUpJSON, SignUpVerificationJSON, SignUpVerificationsJSON, @@ -95,8 +94,6 @@ export type SessionJSONSnapshot = Override< } >; -export type SessionTaskJSONSnapshot = SessionTaskJSON; - export type SignUpJSONSnapshot = Override< Nullable<SignUpJSON, 'status'>, { From c8daa94a09426d2d011d09d481c35263b7037f94 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Fri, 7 Mar 2025 21:11:25 -0300 Subject: [PATCH 05/16] Introduce experimental prop for lazy loading routes --- .../next-app-router/src/app/layout.tsx | 1 + packages/clerk-js/bundlewatch.config.json | 1 - packages/clerk-js/src/core/clerk.ts | 4 +- packages/clerk-js/src/core/sessionTasks.ts | 45 +++++++++---------- .../src/ui/components/SignIn/SignIn.tsx | 24 ++++++---- .../src/ui/components/SignIn/lazy-sign-up.ts | 2 - .../src/ui/components/SignUp/SignUp.tsx | 18 +++++--- .../src/ui/contexts/components/SignIn.ts | 2 + .../src/ui/contexts/components/SignUp.ts | 2 + .../clerk-js/src/ui/lazyModules/components.ts | 6 +++ packages/types/src/clerk.ts | 1 + 11 files changed, 64 insertions(+), 42 deletions(-) diff --git a/integration/templates/next-app-router/src/app/layout.tsx b/integration/templates/next-app-router/src/app/layout.tsx index 2e56184f39d..16e2fce74d5 100644 --- a/integration/templates/next-app-router/src/app/layout.tsx +++ b/integration/templates/next-app-router/src/app/layout.tsx @@ -21,6 +21,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) }, }} experimental={{ + withSessionTasks: true, persistClient: process.env.NEXT_PUBLIC_EXPERIMENTAL_PERSIST_CLIENT ? process.env.NEXT_PUBLIC_EXPERIMENTAL_PERSIST_CLIENT === 'true' : undefined, diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index d9133f5333a..f6f8251ad34 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,6 +1,5 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "570kB" }, { "path": "./dist/clerk.browser.js", "maxSize": "76kB" }, { "path": "./dist/clerk.headless.js", "maxSize": "50KB" }, { "path": "./dist/ui-common*.js", "maxSize": "92KB" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index ec004989fb7..6c1390eef18 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1075,8 +1075,8 @@ export class Clerk implements ClerkInterface { if (session.currentTask) { await navigateToTask(session.currentTask, { - isInternalNavigation: !!this.#internalComponentNavigate, - navigate: this.#internalComponentNavigate ?? this.navigate, + globalNavigate: this.navigate, + internalNavigate: this.#internalComponentNavigate, options: this.#options, environment: this.environment, }); diff --git a/packages/clerk-js/src/core/sessionTasks.ts b/packages/clerk-js/src/core/sessionTasks.ts index ff39cdbd5db..3a296a3d155 100644 --- a/packages/clerk-js/src/core/sessionTasks.ts +++ b/packages/clerk-js/src/core/sessionTasks.ts @@ -6,41 +6,38 @@ export const SESSION_TASK_ROUTE_BY_KEY: Record<SessionTask['key'], string> = { org: '/add-organization', } as const; -function buildTasksUrl(task: SessionTask, options: ClerkOptions, environment: EnvironmentResource): string { - const signInUrl = options['signInUrl'] || environment.displayConfig.signInUrl; - const signUpUrl = options['signUpUrl'] || environment.displayConfig.signUpUrl; - const isReferrerSignUpUrl = window.location.href.startsWith(signUpUrl); - - return buildURL( - // TODO - Accept custom URL option for custom flows in order to eject out of `signInUrl/signUpUrl` - { - base: isReferrerSignUpUrl ? signUpUrl : signInUrl, - hashPath: SESSION_TASK_ROUTE_BY_KEY[task.key], - }, - { stringify: true }, - ); -} - interface NavigateToTaskOptions { - isInternalNavigation: boolean; - navigate: (to: string) => Promise<unknown>; + internalNavigate: ((to: string) => Promise<unknown>) | null; + globalNavigate: (to: string) => Promise<unknown>; options: ClerkOptions; environment: EnvironmentResource; } /** - * Initiates navigation to the tasks URL based on the application context such + * Handles navigation to the tasks URL based on the application context such * as internal component routing or custom flows. * @internal */ export function navigateToTask( task: SessionTask, - { isInternalNavigation, navigate, options, environment }: NavigateToTaskOptions, + { internalNavigate, globalNavigate, options, environment }: NavigateToTaskOptions, ) { - if (!isInternalNavigation) { - // Handles navigation for custom flows, which is triggered outside of UI components routing context - return navigate(buildTasksUrl(task, options, environment)); - } else { - return navigate(SESSION_TASK_ROUTE_BY_KEY['org']); + if (internalNavigate) { + return internalNavigate(SESSION_TASK_ROUTE_BY_KEY['org']); } + + const signInUrl = options['signInUrl'] || environment.displayConfig.signInUrl; + const signUpUrl = options['signUpUrl'] || environment.displayConfig.signUpUrl; + const isReferrerSignUpUrl = window.location.href.startsWith(signUpUrl); + + const taskUrl = buildURL( + // TODO - Accept custom URL option for custom flows in order to eject out of `signInUrl/signUpUrl` + { + base: isReferrerSignUpUrl ? signUpUrl : signInUrl, + hashPath: SESSION_TASK_ROUTE_BY_KEY[task.key], + }, + { stringify: true }, + ); + + return globalNavigate(taskUrl); } diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index b04615ebbb6..1ff0699bb43 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -14,11 +14,10 @@ import { } from '../../contexts'; import { Flow } from '../../customizables'; import { useFetch } from '../../hooks'; +import { preloadSessionTask, SessionTask } from '../../lazyModules/components'; import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; -import { SessionTask } from '../SessionTask'; import { LazySignUpContinue, - LazySignUpSessionTask, LazySignUpSSOCallback, LazySignUpStart, LazySignUpVerifyEmail, @@ -130,9 +129,11 @@ function SignInRoutes(): JSX.Element { > <LazySignUpVerifyPhone /> </Route> - <Route path='add-organization'> - <LazySignUpSessionTask task='org' /> - </Route> + {signInContext.withSessionTasks && ( + <Route path='add-organization'> + <SessionTask task='org' /> + </Route> + )} <Route index> <LazySignUpContinue /> </Route> @@ -142,9 +143,11 @@ function SignInRoutes(): JSX.Element { </Route> </Route> )} - <Route path='add-organization'> - <SessionTask task='org' /> - </Route> + {signInContext.withSessionTasks && ( + <Route path='add-organization'> + <SessionTask task='org' /> + </Route> + )} <Route index> <SignInStart /> </Route> @@ -159,6 +162,9 @@ function SignInRoutes(): JSX.Element { const usePreloadSignUp = (enabled = false) => useFetch(enabled ? preloadSignUp : undefined, 'preloadComponent', { staleTime: Infinity }); +const usePreloadSessionTask = (enabled = false) => + useFetch(enabled ? preloadSessionTask : undefined, 'preloadComponent', { staleTime: Infinity }); + function SignInRoot() { const signInContext = useSignInContext(); const normalizedSignUpContext = { @@ -177,6 +183,8 @@ function SignInRoot() { */ usePreloadSignUp(signInContext.isCombinedFlow); + usePreloadSessionTask(signInContext.withSessionTasks); + return ( <SignUpContext.Provider value={normalizedSignUpContext}> <SignInRoutes /> diff --git a/packages/clerk-js/src/ui/components/SignIn/lazy-sign-up.ts b/packages/clerk-js/src/ui/components/SignIn/lazy-sign-up.ts index 1101083cab1..e3c08113fd9 100644 --- a/packages/clerk-js/src/ui/components/SignIn/lazy-sign-up.ts +++ b/packages/clerk-js/src/ui/components/SignIn/lazy-sign-up.ts @@ -7,7 +7,6 @@ const LazySignUpVerifyEmail = lazy(() => preloadSignUp().then(m => ({ default: m const LazySignUpStart = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpStart }))); const LazySignUpSSOCallback = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpSSOCallback }))); const LazySignUpContinue = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpContinue }))); -const LazySignUpSessionTask = lazy(() => preloadSignUp().then(m => ({ default: m.SessionTask }))); const lazyCompleteSignUpFlow = () => import(/* webpackChunkName: "signUp" */ '../SignUp/util').then(m => m.completeSignUpFlow); @@ -20,5 +19,4 @@ export { LazySignUpSSOCallback, LazySignUpContinue, lazyCompleteSignUpFlow, - LazySignUpSessionTask, }; diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx index 04478446b76..5086f413fba 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx @@ -5,14 +5,18 @@ import React from 'react'; import { SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; import { SignUpContext, useSignUpContext, withCoreSessionSwitchGuard } from '../../contexts'; import { Flow } from '../../customizables'; +import { useFetch } from '../../hooks'; +import { preloadSessionTask, SessionTask } from '../../lazyModules/components'; import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; -import { SessionTask } from '../SessionTask'; import { SignUpContinue } from './SignUpContinue'; import { SignUpSSOCallback } from './SignUpSSOCallback'; import { SignUpStart } from './SignUpStart'; import { SignUpVerifyEmail } from './SignUpVerifyEmail'; import { SignUpVerifyPhone } from './SignUpVerifyPhone'; +const usePreloadSessionTask = (enabled = false) => + useFetch(enabled ? preloadSessionTask : undefined, 'preloadComponent', { staleTime: Infinity }); + function RedirectToSignUp() { const clerk = useClerk(); React.useEffect(() => { @@ -24,6 +28,8 @@ function RedirectToSignUp() { function SignUpRoutes(): JSX.Element { const signUpContext = useSignUpContext(); + usePreloadSessionTask(signUpContext.withSessionTasks); + return ( <Flow.Root flow='signUp'> <Switch> @@ -75,9 +81,11 @@ function SignUpRoutes(): JSX.Element { <SignUpContinue /> </Route> </Route> - <Route path='add-organization'> - <SessionTask task='org' /> - </Route> + {signUpContext.withSessionTasks && ( + <Route path='add-organization'> + <SessionTask task='org' /> + </Route> + )} <Route index> <SignUpStart /> </Route> @@ -122,4 +130,4 @@ export const SignUpModal = (props: SignUpModalProps): JSX.Element => { ); }; -export { SignUpContinue, SignUpSSOCallback, SignUpStart, SignUpVerifyEmail, SignUpVerifyPhone, SessionTask }; +export { SignUpContinue, SignUpSSOCallback, SignUpStart, SignUpVerifyEmail, SignUpVerifyPhone }; diff --git a/packages/clerk-js/src/ui/contexts/components/SignIn.ts b/packages/clerk-js/src/ui/contexts/components/SignIn.ts index bcc6aafed84..320da48584e 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignIn.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignIn.ts @@ -32,6 +32,7 @@ export type SignInContextType = SignInCtx & { emailLinkRedirectUrl: string; ssoCallbackUrl: string; isCombinedFlow: boolean; + withSessionTasks: boolean; }; export const SignInContext = createContext<SignInCtx | null>(null); @@ -142,6 +143,7 @@ export const useSignInContext = (): SignInContextType => { emailLinkRedirectUrl, ssoCallbackUrl, taskUrl, + withSessionTasks: options.experimental?.withSessionTasks, navigateAfterSignIn, signUpContinueUrl, queryParams, diff --git a/packages/clerk-js/src/ui/contexts/components/SignUp.ts b/packages/clerk-js/src/ui/contexts/components/SignUp.ts index 44fdbf06542..660079551c4 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignUp.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignUp.ts @@ -31,6 +31,7 @@ export type SignUpContextType = SignUpCtx & { isCombinedFlow: boolean; emailLinkRedirectUrl: string; ssoCallbackUrl: string; + withSessionTasks: boolean; }; export const SignUpContext = createContext<SignUpCtx | null>(null); @@ -142,5 +143,6 @@ export const useSignUpContext = (): SignUpContextType => { initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, authQueryString, isCombinedFlow, + withSessionTasks: options.experimental?.withSessionTasks, }; }; diff --git a/packages/clerk-js/src/ui/lazyModules/components.ts b/packages/clerk-js/src/ui/lazyModules/components.ts index 51788d64910..6182576c716 100644 --- a/packages/clerk-js/src/ui/lazyModules/components.ts +++ b/packages/clerk-js/src/ui/lazyModules/components.ts @@ -19,6 +19,7 @@ const componentImportPaths = { KeylessPrompt: () => import(/* webpackChunkName: "keylessPrompt" */ '../components/KeylessPrompt'), PricingTable: () => import(/* webpackChunkName: "pricingTable" */ '../components/PricingTable'), Checkout: () => import(/* webpackChunkName: "checkout" */ '../components/Checkout'), + SessionTask: () => import(/* webpackChunkName: "sessionTask" */ '../components/SessionTask'), } as const; export const SignIn = lazy(() => componentImportPaths.SignIn().then(module => ({ default: module.SignIn }))); @@ -94,6 +95,11 @@ export const PricingTable = lazy(() => componentImportPaths.PricingTable().then(module => ({ default: module.__experimental_PricingTable })), ); +export const preloadSessionTask = () => import(/* webpackChunkName: "sessionTask" */ '../components/SessionTask'); +export const SessionTask = lazy(() => + componentImportPaths.SessionTask().then(module => ({ default: module.SessionTask })), +); + export const preloadComponent = async (component: unknown) => { return componentImportPaths[component as keyof typeof componentImportPaths]?.(); }; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index f412b2e8a57..f3c777a000b 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -810,6 +810,7 @@ export type ClerkOptions = ClerkOptionsNavigation & */ rethrowOfflineNetworkErrors: boolean; commerce: boolean; + withSessionTasks: boolean; }, Record<string, any> >; From f34745acf92b7261e2f499ff5c892c287f704464 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Sat, 8 Mar 2025 08:59:11 -0300 Subject: [PATCH 06/16] Send telemetry event --- packages/clerk-js/src/core/sessionTasks.ts | 4 ++-- packages/clerk-js/src/ui/common/withRedirect.tsx | 4 ++-- .../src/ui/components/SessionTask/SessionTask.tsx | 15 +++++++++------ .../clerk-js/src/ui/components/SignIn/SignIn.tsx | 4 ++-- .../clerk-js/src/ui/components/SignUp/SignUp.tsx | 4 ++-- .../clerk-js/src/ui/contexts/components/SignIn.ts | 6 +++--- .../clerk-js/src/ui/contexts/components/SignUp.ts | 6 +++--- 7 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/clerk-js/src/core/sessionTasks.ts b/packages/clerk-js/src/core/sessionTasks.ts index 3a296a3d155..e7aece853e3 100644 --- a/packages/clerk-js/src/core/sessionTasks.ts +++ b/packages/clerk-js/src/core/sessionTasks.ts @@ -30,7 +30,7 @@ export function navigateToTask( const signUpUrl = options['signUpUrl'] || environment.displayConfig.signUpUrl; const isReferrerSignUpUrl = window.location.href.startsWith(signUpUrl); - const taskUrl = buildURL( + const sessionTaskUrl = buildURL( // TODO - Accept custom URL option for custom flows in order to eject out of `signInUrl/signUpUrl` { base: isReferrerSignUpUrl ? signUpUrl : signInUrl, @@ -39,5 +39,5 @@ export function navigateToTask( { stringify: true }, ); - return globalNavigate(taskUrl); + return globalNavigate(sessionTaskUrl); } diff --git a/packages/clerk-js/src/ui/common/withRedirect.tsx b/packages/clerk-js/src/ui/common/withRedirect.tsx index b1d77dcd759..9e0f3b3deed 100644 --- a/packages/clerk-js/src/ui/common/withRedirect.tsx +++ b/packages/clerk-js/src/ui/common/withRedirect.tsx @@ -61,7 +61,7 @@ export const withRedirectToAfterSignIn = <P extends AvailableComponentProps>(Com return withRedirect( Component, sessionExistsAndSingleSessionModeEnabled, - ({ clerk }) => signInCtx.taskUrl || signInCtx.afterSignInUrl || clerk.buildAfterSignInUrl(), + ({ clerk }) => signInCtx.sessionTaskUrl || signInCtx.afterSignInUrl || clerk.buildAfterSignInUrl(), warnings.cannotRenderSignInComponentWhenSessionExists, )(props); }; @@ -80,7 +80,7 @@ export const withRedirectToAfterSignUp = <P extends AvailableComponentProps>(Com return withRedirect( Component, sessionExistsAndSingleSessionModeEnabled, - ({ clerk }) => signUpCtx.taskUrl || signUpCtx.afterSignUpUrl || clerk.buildAfterSignUpUrl(), + ({ clerk }) => signUpCtx.sessionTaskUrl || signUpCtx.afterSignUpUrl || clerk.buildAfterSignUpUrl(), warnings.cannotRenderSignUpComponentWhenSessionExists, )(props); }; diff --git a/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx b/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx index 0f0cf55fa6e..b869d62265e 100644 --- a/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx +++ b/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx @@ -1,13 +1,11 @@ +import { useClerk } from '@clerk/shared/react/index'; +import { eventComponentMounted } from '@clerk/shared/telemetry'; import type { SessionTask } from '@clerk/types'; -import { type ComponentType } from 'react'; import { OrganizationListContext } from '../../contexts'; import { OrganizationList } from '../OrganizationList'; -/** - * @internal - */ -const SessionTaskRegistry: Record<SessionTask['key'], ComponentType> = { +const ContentRegistry: Record<SessionTask['key'], React.ComponentType> = { org: () => ( // TODO - Hide personal workspace within organization list context based on environment <OrganizationListContext.Provider value={{ componentName: 'OrganizationList', hidePersonal: true }}> @@ -20,6 +18,11 @@ const SessionTaskRegistry: Record<SessionTask['key'], ComponentType> = { * @internal */ export function SessionTask({ task }: { task: SessionTask['key'] }): React.ReactNode { - const Content = SessionTaskRegistry[task]; + const clerk = useClerk(); + + clerk.telemetry?.record(eventComponentMounted('SessionTask', { task })); + + const Content = ContentRegistry[task]; + return <Content />; } diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index 1ff0699bb43..1c4daeba125 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -14,7 +14,7 @@ import { } from '../../contexts'; import { Flow } from '../../customizables'; import { useFetch } from '../../hooks'; -import { preloadSessionTask, SessionTask } from '../../lazyModules/components'; +import { preloadComponent, SessionTask } from '../../lazyModules/components'; import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; import { LazySignUpContinue, @@ -163,7 +163,7 @@ const usePreloadSignUp = (enabled = false) => useFetch(enabled ? preloadSignUp : undefined, 'preloadComponent', { staleTime: Infinity }); const usePreloadSessionTask = (enabled = false) => - useFetch(enabled ? preloadSessionTask : undefined, 'preloadComponent', { staleTime: Infinity }); + useFetch(enabled ? void preloadComponent('SessionTask') : undefined, 'preloadComponent', { staleTime: Infinity }); function SignInRoot() { const signInContext = useSignInContext(); diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx index 5086f413fba..810770e27f0 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx @@ -6,7 +6,7 @@ import { SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowC import { SignUpContext, useSignUpContext, withCoreSessionSwitchGuard } from '../../contexts'; import { Flow } from '../../customizables'; import { useFetch } from '../../hooks'; -import { preloadSessionTask, SessionTask } from '../../lazyModules/components'; +import { preloadComponent, SessionTask } from '../../lazyModules/components'; import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; import { SignUpContinue } from './SignUpContinue'; import { SignUpSSOCallback } from './SignUpSSOCallback'; @@ -15,7 +15,7 @@ import { SignUpVerifyEmail } from './SignUpVerifyEmail'; import { SignUpVerifyPhone } from './SignUpVerifyPhone'; const usePreloadSessionTask = (enabled = false) => - useFetch(enabled ? preloadSessionTask : undefined, 'preloadComponent', { staleTime: Infinity }); + useFetch(enabled ? void preloadComponent('SessionTask') : undefined, 'preloadComponent', { staleTime: Infinity }); function RedirectToSignUp() { const clerk = useClerk(); diff --git a/packages/clerk-js/src/ui/contexts/components/SignIn.ts b/packages/clerk-js/src/ui/contexts/components/SignIn.ts index 320da48584e..c8aead4f2c8 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignIn.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignIn.ts @@ -26,7 +26,7 @@ export type SignInContextType = SignInCtx & { authQueryString: string | null; afterSignUpUrl: string; afterSignInUrl: string; - taskUrl: string | null; + sessionTaskUrl: string | null; transferable: boolean; waitlistUrl: string; emailLinkRedirectUrl: string; @@ -119,7 +119,7 @@ export const useSignInContext = (): SignInContextType => { const signUpContinueUrl = buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true }); - const taskUrl = clerk.session?.currentTask + const sessionTaskUrl = clerk.session?.currentTask ? buildSessionTaskRedirectUrl({ routing: ctx.routing, path: ctx.path }, signInUrl, clerk.session?.currentTask) : null; @@ -142,7 +142,7 @@ export const useSignInContext = (): SignInContextType => { afterSignUpUrl, emailLinkRedirectUrl, ssoCallbackUrl, - taskUrl, + sessionTaskUrl, withSessionTasks: options.experimental?.withSessionTasks, navigateAfterSignIn, signUpContinueUrl, diff --git a/packages/clerk-js/src/ui/contexts/components/SignUp.ts b/packages/clerk-js/src/ui/contexts/components/SignUp.ts index 660079551c4..992cfd4ad08 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignUp.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignUp.ts @@ -27,7 +27,7 @@ export type SignUpContextType = SignUpCtx & { afterSignUpUrl: string; afterSignInUrl: string; waitlistUrl: string; - taskUrl: string | null; + sessionTaskUrl: string | null; isCombinedFlow: boolean; emailLinkRedirectUrl: string; ssoCallbackUrl: string; @@ -114,7 +114,7 @@ export const useSignUpContext = (): SignUpContextType => { // TODO: Avoid building this url again to remove duplicate code. Get it from window.Clerk instead. const secondFactorUrl = buildURL({ base: signInUrl, hashPath: '/factor-two' }, { stringify: true }); - const taskUrl = clerk.session?.currentTask + const sessionTaskUrl = clerk.session?.currentTask ? buildSessionTaskRedirectUrl({ routing: ctx.routing, path: ctx.path }, signUpUrl, clerk.session?.currentTask) : null; @@ -137,7 +137,7 @@ export const useSignUpContext = (): SignUpContextType => { afterSignInUrl, emailLinkRedirectUrl, ssoCallbackUrl, - taskUrl, + sessionTaskUrl, navigateAfterSignUp, queryParams, initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, From 250d69cfa172c966a31a0ec6e3cfc38d23e21606 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Mon, 10 Mar 2025 18:42:30 -0300 Subject: [PATCH 07/16] Refactor internal routing to set on `BaseRouter` --- .../next-app-router/src/app/layout.tsx | 3 ++- packages/clerk-js/src/core/clerk.ts | 22 ++++++++++++++---- packages/clerk-js/src/core/sessionTasks.ts | 23 +++++++++++++++---- .../src/ui/components/SignIn/SignIn.tsx | 4 ++-- .../src/ui/components/SignUp/SignUp.tsx | 4 ++-- .../src/ui/contexts/components/SignIn.ts | 12 ++-------- .../src/ui/contexts/components/SignUp.ts | 12 ++-------- .../clerk-js/src/ui/router/BaseRouter.tsx | 6 ++++- packages/react/src/isomorphicClerk.ts | 2 +- packages/types/src/clerk.ts | 11 ++++++--- 10 files changed, 60 insertions(+), 39 deletions(-) diff --git a/integration/templates/next-app-router/src/app/layout.tsx b/integration/templates/next-app-router/src/app/layout.tsx index 16e2fce74d5..f83c142ffcb 100644 --- a/integration/templates/next-app-router/src/app/layout.tsx +++ b/integration/templates/next-app-router/src/app/layout.tsx @@ -21,10 +21,11 @@ export default function RootLayout({ children }: { children: React.ReactNode }) }, }} experimental={{ - withSessionTasks: true, persistClient: process.env.NEXT_PUBLIC_EXPERIMENTAL_PERSIST_CLIENT ? process.env.NEXT_PUBLIC_EXPERIMENTAL_PERSIST_CLIENT === 'true' : undefined, + // `withSessionTasks` will be removed soon in favor of checking via environment response + withSessionTasks: true, }} > <html lang='en'> diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 6c1390eef18..dd8bb1449a8 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -200,7 +200,10 @@ export class Clerk implements ClerkInterface { #options: ClerkOptions = {}; #pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null; #touchThrottledUntil = 0; - #internalComponentNavigate: ((to: string) => Promise<unknown>) | null = null; + #componentNavigationContext: { + navigate: (toURL: URL | undefined) => Promise<unknown>; + basePath: string; + } | null = null; public __internal_getCachedResources: | (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>) @@ -1076,12 +1079,14 @@ export class Clerk implements ClerkInterface { if (session.currentTask) { await navigateToTask(session.currentTask, { globalNavigate: this.navigate, - internalNavigate: this.#internalComponentNavigate, + componentNavigationContext: this.#componentNavigationContext, options: this.#options, environment: this.environment, }); - this.#internalComponentNavigate = null; + // Reset component navigation context once navigation finishes + // to not conflict with after sign-in / after sign-up + this.#componentNavigationContext = null; } this.#setAccessors(session); @@ -1115,8 +1120,15 @@ export class Clerk implements ClerkInterface { return unsubscribe; }; - public __internal_setComponentNavigate = (navigate: (to: string) => Promise<unknown>) => { - this.#internalComponentNavigate = navigate; + public __internal_setComponentNavigationContext = ( + context: { + navigate: (toURL: URL | undefined) => Promise<unknown>; + basePath: string; + } | null, + ) => { + this.#componentNavigationContext = context; + + return () => (this.#componentNavigationContext = null); }; public navigate = async (to: string | undefined, options?: NavigateOptions): Promise<unknown> => { diff --git a/packages/clerk-js/src/core/sessionTasks.ts b/packages/clerk-js/src/core/sessionTasks.ts index e7aece853e3..07ca4dc18d9 100644 --- a/packages/clerk-js/src/core/sessionTasks.ts +++ b/packages/clerk-js/src/core/sessionTasks.ts @@ -7,7 +7,10 @@ export const SESSION_TASK_ROUTE_BY_KEY: Record<SessionTask['key'], string> = { } as const; interface NavigateToTaskOptions { - internalNavigate: ((to: string) => Promise<unknown>) | null; + componentNavigationContext: { + basePath: string; + navigate: (toURL: URL | undefined) => Promise<unknown>; + } | null; globalNavigate: (to: string) => Promise<unknown>; options: ClerkOptions; environment: EnvironmentResource; @@ -20,10 +23,22 @@ interface NavigateToTaskOptions { */ export function navigateToTask( task: SessionTask, - { internalNavigate, globalNavigate, options, environment }: NavigateToTaskOptions, + { componentNavigationContext, globalNavigate, options, environment }: NavigateToTaskOptions, ) { - if (internalNavigate) { - return internalNavigate(SESSION_TASK_ROUTE_BY_KEY['org']); + if (componentNavigationContext) { + const isHashRouting = !!new URL(window.location.href).hash; + const taskUrl = buildURL({ + base: componentNavigationContext.basePath, + ...(isHashRouting + ? { + hashPath: SESSION_TASK_ROUTE_BY_KEY[task.key], + } + : { + pathname: componentNavigationContext.basePath + SESSION_TASK_ROUTE_BY_KEY[task.key], + }), + }) as URL; + + return componentNavigationContext.navigate(taskUrl); } const signInUrl = options['signInUrl'] || environment.displayConfig.signInUrl; diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index 1c4daeba125..1ff0699bb43 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -14,7 +14,7 @@ import { } from '../../contexts'; import { Flow } from '../../customizables'; import { useFetch } from '../../hooks'; -import { preloadComponent, SessionTask } from '../../lazyModules/components'; +import { preloadSessionTask, SessionTask } from '../../lazyModules/components'; import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; import { LazySignUpContinue, @@ -163,7 +163,7 @@ const usePreloadSignUp = (enabled = false) => useFetch(enabled ? preloadSignUp : undefined, 'preloadComponent', { staleTime: Infinity }); const usePreloadSessionTask = (enabled = false) => - useFetch(enabled ? void preloadComponent('SessionTask') : undefined, 'preloadComponent', { staleTime: Infinity }); + useFetch(enabled ? preloadSessionTask : undefined, 'preloadComponent', { staleTime: Infinity }); function SignInRoot() { const signInContext = useSignInContext(); diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx index 810770e27f0..5086f413fba 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx @@ -6,7 +6,7 @@ import { SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowC import { SignUpContext, useSignUpContext, withCoreSessionSwitchGuard } from '../../contexts'; import { Flow } from '../../customizables'; import { useFetch } from '../../hooks'; -import { preloadComponent, SessionTask } from '../../lazyModules/components'; +import { preloadSessionTask, SessionTask } from '../../lazyModules/components'; import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; import { SignUpContinue } from './SignUpContinue'; import { SignUpSSOCallback } from './SignUpSSOCallback'; @@ -15,7 +15,7 @@ import { SignUpVerifyEmail } from './SignUpVerifyEmail'; import { SignUpVerifyPhone } from './SignUpVerifyPhone'; const usePreloadSessionTask = (enabled = false) => - useFetch(enabled ? void preloadComponent('SessionTask') : undefined, 'preloadComponent', { staleTime: Infinity }); + useFetch(enabled ? preloadSessionTask : undefined, 'preloadComponent', { staleTime: Infinity }); function RedirectToSignUp() { const clerk = useClerk(); diff --git a/packages/clerk-js/src/ui/contexts/components/SignIn.ts b/packages/clerk-js/src/ui/contexts/components/SignIn.ts index c8aead4f2c8..d8fbbb7e961 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignIn.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignIn.ts @@ -1,6 +1,6 @@ import { useClerk } from '@clerk/shared/react'; import { isAbsoluteUrl } from '@clerk/shared/url'; -import { createContext, useContext, useEffect, useMemo } from 'react'; +import { createContext, useContext, useMemo } from 'react'; import { SIGN_IN_INITIAL_VALUE_KEYS } from '../../../core/constants'; import { buildURL } from '../../../utils'; @@ -123,14 +123,6 @@ export const useSignInContext = (): SignInContextType => { ? buildSessionTaskRedirectUrl({ routing: ctx.routing, path: ctx.path }, signInUrl, clerk.session?.currentTask) : null; - useEffect(() => { - clerk.__internal_setComponentNavigate((endpoint: string) => - navigate( - buildRedirectUrl({ routing: ctx.routing, path: ctx.path, baseUrl: signInUrl, endpoint, authQueryString }), - ), - ); - }, []); - return { ...(ctx as SignInCtx), transferable: ctx.transferable ?? true, @@ -143,12 +135,12 @@ export const useSignInContext = (): SignInContextType => { emailLinkRedirectUrl, ssoCallbackUrl, sessionTaskUrl, - withSessionTasks: options.experimental?.withSessionTasks, navigateAfterSignIn, signUpContinueUrl, queryParams, initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, authQueryString, isCombinedFlow, + withSessionTasks: !!options.experimental?.withSessionTasks, }; }; diff --git a/packages/clerk-js/src/ui/contexts/components/SignUp.ts b/packages/clerk-js/src/ui/contexts/components/SignUp.ts index 992cfd4ad08..3b16a4fd147 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignUp.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignUp.ts @@ -1,6 +1,6 @@ import { useClerk } from '@clerk/shared/react'; import { isAbsoluteUrl } from '@clerk/shared/url'; -import { createContext, useContext, useEffect, useMemo } from 'react'; +import { createContext, useContext, useMemo } from 'react'; import { SIGN_UP_INITIAL_VALUE_KEYS } from '../../../core/constants'; import { buildURL } from '../../../utils'; @@ -118,14 +118,6 @@ export const useSignUpContext = (): SignUpContextType => { ? buildSessionTaskRedirectUrl({ routing: ctx.routing, path: ctx.path }, signUpUrl, clerk.session?.currentTask) : null; - useEffect(() => { - clerk.__internal_setComponentNavigate((endpoint: string) => - navigate( - buildRedirectUrl({ routing: ctx.routing, path: ctx.path, baseUrl: signUpUrl, endpoint, authQueryString }), - ), - ); - }, []); - return { ...ctx, componentName, @@ -143,6 +135,6 @@ export const useSignUpContext = (): SignUpContextType => { initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, authQueryString, isCombinedFlow, - withSessionTasks: options.experimental?.withSessionTasks, + withSessionTasks: !!options.experimental?.withSessionTasks, }; }; diff --git a/packages/clerk-js/src/ui/router/BaseRouter.tsx b/packages/clerk-js/src/ui/router/BaseRouter.tsx index 1874116aa04..c13d0d17b04 100644 --- a/packages/clerk-js/src/ui/router/BaseRouter.tsx +++ b/packages/clerk-js/src/ui/router/BaseRouter.tsx @@ -40,7 +40,7 @@ export const BaseRouter = ({ }: BaseRouterProps): JSX.Element => { // Disabling is acceptable since this is a Router component // eslint-disable-next-line custom-rules/no-navigate-useClerk - const { navigate: clerkNavigate } = useClerk(); + const { navigate: clerkNavigate, __internal_setComponentNavigationContext } = useClerk(); const [routeParts, setRouteParts] = React.useState({ path: getPath(), @@ -121,6 +121,10 @@ export const BaseRouter = ({ return internalNavRes; }; + React.useEffect(() => { + return __internal_setComponentNavigationContext?.({ basePath, navigate: baseNavigate }); + }, []); + return ( <RouteContext.Provider value={{ diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 7db4b79b573..c2b85093152 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -93,7 +93,7 @@ type IsomorphicLoadedClerk = Without< | '__internal_getCachedResources' | '__internal_reloadInitialResources' | '__experimental_commerce' - | '__internal_setComponentNavigate' + | '__internal_setComponentNavigationContext' > & { client: ClientResource | undefined; __experimental_commerce: __experimental_CommerceNamespace | undefined; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index f3c777a000b..66b1ddffe7a 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -427,11 +427,16 @@ export interface Clerk { __internal_addNavigationListener: (callback: () => void) => UnsubscribeCallback; /** - * Registers an internal navigate function for UI components in order to be triggered - * from `Clerk` + * Registers the internal navigation context from UI components in order to + * be triggered from `Clerk` methods * @internal */ - __internal_setComponentNavigate: (navigate: (to: string) => Promise<unknown>) => void; + __internal_setComponentNavigationContext: ( + context: { + navigate: (toURL: URL | undefined) => Promise<unknown>; + basePath: string; + } | null, + ) => () => void; /** * Set the active session and organization explicitly. From f27b7c25bd2251d516ff96e366d11b0efe7d0717 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 11 Mar 2025 10:37:33 -0300 Subject: [PATCH 08/16] Refactor integration tests to use use long running apps --- .../tests/session-tasks-sign-in.test.ts | 59 +++++++++---------- .../tests/session-tasks-sign-up.test.ts | 58 ++++++++---------- packages/clerk-js/bundlewatch.config.json | 3 +- 3 files changed, 56 insertions(+), 64 deletions(-) diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts index 8e4ae9e7a2b..eebc35002c1 100644 --- a/integration/tests/session-tasks-sign-in.test.ts +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -1,41 +1,38 @@ import { expect, test } from '@playwright/test'; -import type { Application } from '../models/application'; import { appConfigs } from '../presets'; import type { FakeUser } from '../testUtils'; -import { createTestUtils } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; -test.describe('session tasks after sign-in flow @nextjs', () => { - test.describe.configure({ mode: 'serial' }); - let app: Application; - let fakeUser: FakeUser; +testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( + 'session tasks after sign-in flow @nextjs', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); - test.beforeAll(async () => { - app = await appConfigs.next.appRouter.clone().commit(); - await app.setup(); - await app.withEnv(appConfigs.envs.withSessionTasks); - await app.dev(); + let fakeUser: FakeUser; - const m = createTestUtils({ app }); - fakeUser = m.services.users.createFakeUser(); - await m.services.users.createBapiUser(fakeUser); - }); + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); - test.afterAll(async () => { - await fakeUser.deleteIfExists(); - await app.teardown(); - }); + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); - test('navigate to task on after sign-in', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - 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(); + test('navigate to task on after sign-in', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + 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(); - await expect(u.page.getByRole('button', { name: /create organization/i })).toBeVisible(); - expect(page.url()).toContain('add-organization'); - }); -}); + await expect(u.page.getByRole('button', { name: /create organization/i })).toBeVisible(); + expect(page.url()).toContain('add-organization'); + }); + }, +); diff --git a/integration/tests/session-tasks-sign-up.test.ts b/integration/tests/session-tasks-sign-up.test.ts index 6270b8967a1..ad5003eb2f3 100644 --- a/integration/tests/session-tasks-sign-up.test.ts +++ b/integration/tests/session-tasks-sign-up.test.ts @@ -1,40 +1,34 @@ import { expect, test } from '@playwright/test'; -import type { Application } from '../models/application'; import { appConfigs } from '../presets'; -import { createTestUtils } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; -test.describe('session tasks after sign-up flow @nextjs', () => { - test.describe.configure({ mode: 'serial' }); - let app: Application; +testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( + 'session tasks after sign-up flow @nextjs', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); - test.beforeAll(async () => { - app = await appConfigs.next.appRouter.clone().commit(); - await app.setup(); - await app.withEnv(appConfigs.envs.withSessionTasks); - await app.dev(); - }); - - test.afterAll(async () => { - await app.teardown(); - }); - - test('navigate to task on after sign-up', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - const fakeUser = u.services.users.createFakeUser({ - fictionalEmail: true, - withPhoneNumber: true, - withUsername: true, - }); - await u.po.signUp.goTo(); - await u.po.signUp.signUpWithEmailAndPassword({ - email: fakeUser.email, - password: fakeUser.password, + test.afterAll(async () => { + await app.teardown(); }); - await expect(u.page.getByRole('button', { name: /create organization/i })).toBeVisible(); - expect(page.url()).toContain('add-organization'); + test('navigate to task on after sign-up', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + const fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPhoneNumber: true, + withUsername: true, + }); + await u.po.signUp.goTo(); + await u.po.signUp.signUpWithEmailAndPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); - await fakeUser.deleteIfExists(); - }); -}); + await expect(u.page.getByRole('button', { name: /create organization/i })).toBeVisible(); + expect(page.url()).toContain('add-organization'); + + await fakeUser.deleteIfExists(); + }); + }, +); diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index f6f8251ad34..f62025992aa 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,5 +1,6 @@ { "files": [ + { "path": "./dist/clerk.js", "maxSize": "570kB" }, { "path": "./dist/clerk.browser.js", "maxSize": "76kB" }, { "path": "./dist/clerk.headless.js", "maxSize": "50KB" }, { "path": "./dist/ui-common*.js", "maxSize": "92KB" }, @@ -11,7 +12,7 @@ { "path": "./dist/organizationswitcher*.js", "maxSize": "5KB" }, { "path": "./dist/organizationlist*.js", "maxSize": "5.5KB" }, { "path": "./dist/signin*.js", "maxSize": "12.4KB" }, - { "path": "./dist/signup*.js", "maxSize": "6.4KB" }, + { "path": "./dist/signup*.js", "maxSize": "6.5KB" }, { "path": "./dist/userbutton*.js", "maxSize": "5KB" }, { "path": "./dist/userprofile*.js", "maxSize": "15KB" }, { "path": "./dist/userverification*.js", "maxSize": "5KB" }, From bb6c8dd1daf8673047a83bb915291129f4c76bac Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 11 Mar 2025 19:03:37 -0300 Subject: [PATCH 09/16] Remove unused `TokenUpdate` dispatch --- packages/clerk-js/src/core/clerk.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index dd8bb1449a8..07135756734 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1058,10 +1058,6 @@ export class Clerk implements ClerkInterface { return; } - if (session.lastActiveToken) { - eventBus.dispatch(events.TokenUpdate, { token: session.lastActiveToken }); - } - // Handles multi-session scenario when switching from `active` // to `pending` if (inActiveBrowserTab() || !this.#options.standardBrowser) { From 68c2a041c2d6bf69f4bf5c6f924d53cd96220cfc Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 11 Mar 2025 19:49:28 -0300 Subject: [PATCH 10/16] Refactor internal routing logic - Use the typed constant when declaring Route components to catch breaking changes on `path` changes - Call `setComponentNavigationContext` within SignIn/SignUp root components to trigger cleanup on unmount, instead of relying on `BaseRouter` - Remove cleanup for `Clerk.#componentNavigationContext` withi `Clerk.#handlePendingSession` --- packages/clerk-js/src/core/clerk.ts | 18 ++++++++----- packages/clerk-js/src/core/sessionTasks.ts | 27 ++++++++----------- packages/clerk-js/src/ui/common/redirects.ts | 20 +++++++++----- .../src/ui/components/SignIn/SignIn.tsx | 14 +++++++--- .../src/ui/components/SignUp/SignUp.tsx | 11 ++++++-- .../src/ui/contexts/components/SignIn.ts | 9 ++++--- .../src/ui/contexts/components/SignUp.ts | 9 ++++--- .../clerk-js/src/ui/router/BaseRouter.tsx | 6 +---- packages/types/src/clerk.ts | 7 ++++- 9 files changed, 75 insertions(+), 46 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 07135756734..936743ff582 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -201,7 +201,12 @@ export class Clerk implements ClerkInterface { #pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null; #touchThrottledUntil = 0; #componentNavigationContext: { - navigate: (toURL: URL | undefined) => Promise<unknown>; + navigate: ( + to: string, + options?: { + searchParams?: URLSearchParams; + }, + ) => Promise<unknown>; basePath: string; } | null = null; @@ -1079,10 +1084,6 @@ export class Clerk implements ClerkInterface { options: this.#options, environment: this.environment, }); - - // Reset component navigation context once navigation finishes - // to not conflict with after sign-in / after sign-up - this.#componentNavigationContext = null; } this.#setAccessors(session); @@ -1118,7 +1119,12 @@ export class Clerk implements ClerkInterface { public __internal_setComponentNavigationContext = ( context: { - navigate: (toURL: URL | undefined) => Promise<unknown>; + navigate: ( + to: string, + options?: { + searchParams?: URLSearchParams; + }, + ) => Promise<unknown>; basePath: string; } | null, ) => { diff --git a/packages/clerk-js/src/core/sessionTasks.ts b/packages/clerk-js/src/core/sessionTasks.ts index 07ca4dc18d9..29d7d9e0481 100644 --- a/packages/clerk-js/src/core/sessionTasks.ts +++ b/packages/clerk-js/src/core/sessionTasks.ts @@ -3,13 +3,18 @@ import type { ClerkOptions, EnvironmentResource, SessionTask } from '@clerk/type import { buildURL } from '../utils'; export const SESSION_TASK_ROUTE_BY_KEY: Record<SessionTask['key'], string> = { - org: '/add-organization', + org: 'add-organization', } as const; interface NavigateToTaskOptions { componentNavigationContext: { + navigate: ( + to: string, + options?: { + searchParams?: URLSearchParams; + }, + ) => Promise<unknown>; basePath: string; - navigate: (toURL: URL | undefined) => Promise<unknown>; } | null; globalNavigate: (to: string) => Promise<unknown>; options: ClerkOptions; @@ -25,20 +30,10 @@ export function navigateToTask( task: SessionTask, { componentNavigationContext, globalNavigate, options, environment }: NavigateToTaskOptions, ) { + const taskRoute = `/${SESSION_TASK_ROUTE_BY_KEY[task.key]}`; + if (componentNavigationContext) { - const isHashRouting = !!new URL(window.location.href).hash; - const taskUrl = buildURL({ - base: componentNavigationContext.basePath, - ...(isHashRouting - ? { - hashPath: SESSION_TASK_ROUTE_BY_KEY[task.key], - } - : { - pathname: componentNavigationContext.basePath + SESSION_TASK_ROUTE_BY_KEY[task.key], - }), - }) as URL; - - return componentNavigationContext.navigate(taskUrl); + return componentNavigationContext.navigate(`/${componentNavigationContext.basePath + taskRoute}`); } const signInUrl = options['signInUrl'] || environment.displayConfig.signInUrl; @@ -49,7 +44,7 @@ export function navigateToTask( // TODO - Accept custom URL option for custom flows in order to eject out of `signInUrl/signUpUrl` { base: isReferrerSignUpUrl ? signUpUrl : signInUrl, - hashPath: SESSION_TASK_ROUTE_BY_KEY[task.key], + hashPath: taskRoute, }, { stringify: true }, ); diff --git a/packages/clerk-js/src/ui/common/redirects.ts b/packages/clerk-js/src/ui/common/redirects.ts index add5954cfeb..6b785c9d694 100644 --- a/packages/clerk-js/src/ui/common/redirects.ts +++ b/packages/clerk-js/src/ui/common/redirects.ts @@ -28,18 +28,24 @@ export function buildVerificationRedirectUrl({ }); } -export function buildSessionTaskRedirectUrl( - ctx: Pick<SignInContextType | SignUpContextType, 'routing' | 'path'>, - baseUrl: string, - task: SessionTask, -) { - const { routing, path } = ctx; +export function buildSessionTaskRedirectUrl({ + routing, + path, + baseUrl, + task, +}: Pick<SignInContextType | SignUpContextType, 'routing' | 'path'> & { + baseUrl: string; + task?: SessionTask; +}) { + if (!task) { + return null; + } return buildRedirectUrl({ routing, baseUrl, path, - endpoint: SESSION_TASK_ROUTE_BY_KEY[task.key], + endpoint: `/${SESSION_TASK_ROUTE_BY_KEY[task.key]}`, authQueryString: null, }); } diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index 1ff0699bb43..0cbb5a25cc0 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -2,6 +2,7 @@ import { useClerk } from '@clerk/shared/react'; import type { SignInModalProps, SignInProps } from '@clerk/types'; import React from 'react'; +import { SESSION_TASK_ROUTE_BY_KEY } from '../../../core/sessionTasks'; import { normalizeRoutingOptions } from '../../../utils/normalizeRoutingOptions'; import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; import type { SignUpContextType } from '../../contexts'; @@ -15,7 +16,7 @@ import { import { Flow } from '../../customizables'; import { useFetch } from '../../hooks'; import { preloadSessionTask, SessionTask } from '../../lazyModules/components'; -import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; +import { Route, Switch, useRouter, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; import { LazySignUpContinue, LazySignUpSSOCallback, @@ -130,7 +131,7 @@ function SignInRoutes(): JSX.Element { <LazySignUpVerifyPhone /> </Route> {signInContext.withSessionTasks && ( - <Route path='add-organization'> + <Route path={SESSION_TASK_ROUTE_BY_KEY['org']}> <SessionTask task='org' /> </Route> )} @@ -144,7 +145,7 @@ function SignInRoutes(): JSX.Element { </Route> )} {signInContext.withSessionTasks && ( - <Route path='add-organization'> + <Route path={SESSION_TASK_ROUTE_BY_KEY['org']}> <SessionTask task='org' /> </Route> )} @@ -166,6 +167,9 @@ const usePreloadSessionTask = (enabled = false) => useFetch(enabled ? preloadSessionTask : undefined, 'preloadComponent', { staleTime: Infinity }); function SignInRoot() { + const { __internal_setComponentNavigationContext } = useClerk(); + const { navigate, basePath } = useRouter(); + const signInContext = useSignInContext(); const normalizedSignUpContext = { componentName: 'SignUp', @@ -185,6 +189,10 @@ function SignInRoot() { usePreloadSessionTask(signInContext.withSessionTasks); + React.useEffect(() => { + return __internal_setComponentNavigationContext?.({ basePath, navigate }); + }, []); + return ( <SignUpContext.Provider value={normalizedSignUpContext}> <SignInRoutes /> diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx index 5086f413fba..a3ed31c64f4 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx @@ -2,12 +2,13 @@ import { useClerk } from '@clerk/shared/react'; import type { SignUpModalProps, SignUpProps } from '@clerk/types'; import React from 'react'; +import { SESSION_TASK_ROUTE_BY_KEY } from '../../../core/sessionTasks'; import { SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; import { SignUpContext, useSignUpContext, withCoreSessionSwitchGuard } from '../../contexts'; import { Flow } from '../../customizables'; import { useFetch } from '../../hooks'; import { preloadSessionTask, SessionTask } from '../../lazyModules/components'; -import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; +import { Route, Switch, useRouter, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; import { SignUpContinue } from './SignUpContinue'; import { SignUpSSOCallback } from './SignUpSSOCallback'; import { SignUpStart } from './SignUpStart'; @@ -26,10 +27,16 @@ function RedirectToSignUp() { } function SignUpRoutes(): JSX.Element { + const { __internal_setComponentNavigationContext } = useClerk(); + const { navigate, basePath } = useRouter(); const signUpContext = useSignUpContext(); usePreloadSessionTask(signUpContext.withSessionTasks); + React.useEffect(() => { + return __internal_setComponentNavigationContext?.({ basePath, navigate }); + }, []); + return ( <Flow.Root flow='signUp'> <Switch> @@ -82,7 +89,7 @@ function SignUpRoutes(): JSX.Element { </Route> </Route> {signUpContext.withSessionTasks && ( - <Route path='add-organization'> + <Route path={SESSION_TASK_ROUTE_BY_KEY['org']}> <SessionTask task='org' /> </Route> )} diff --git a/packages/clerk-js/src/ui/contexts/components/SignIn.ts b/packages/clerk-js/src/ui/contexts/components/SignIn.ts index d8fbbb7e961..d709a0d14b4 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignIn.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignIn.ts @@ -119,9 +119,12 @@ export const useSignInContext = (): SignInContextType => { const signUpContinueUrl = buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true }); - const sessionTaskUrl = clerk.session?.currentTask - ? buildSessionTaskRedirectUrl({ routing: ctx.routing, path: ctx.path }, signInUrl, clerk.session?.currentTask) - : null; + const sessionTaskUrl = buildSessionTaskRedirectUrl({ + task: clerk.session?.currentTask, + path: ctx.path, + routing: ctx.routing, + baseUrl: signInUrl, + }); return { ...(ctx as SignInCtx), diff --git a/packages/clerk-js/src/ui/contexts/components/SignUp.ts b/packages/clerk-js/src/ui/contexts/components/SignUp.ts index 3b16a4fd147..59ce5f4ab09 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignUp.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignUp.ts @@ -114,9 +114,12 @@ export const useSignUpContext = (): SignUpContextType => { // TODO: Avoid building this url again to remove duplicate code. Get it from window.Clerk instead. const secondFactorUrl = buildURL({ base: signInUrl, hashPath: '/factor-two' }, { stringify: true }); - const sessionTaskUrl = clerk.session?.currentTask - ? buildSessionTaskRedirectUrl({ routing: ctx.routing, path: ctx.path }, signUpUrl, clerk.session?.currentTask) - : null; + const sessionTaskUrl = buildSessionTaskRedirectUrl({ + task: clerk.session?.currentTask, + path: ctx.path, + routing: ctx.routing, + baseUrl: signUpUrl, + }); return { ...ctx, diff --git a/packages/clerk-js/src/ui/router/BaseRouter.tsx b/packages/clerk-js/src/ui/router/BaseRouter.tsx index c13d0d17b04..1874116aa04 100644 --- a/packages/clerk-js/src/ui/router/BaseRouter.tsx +++ b/packages/clerk-js/src/ui/router/BaseRouter.tsx @@ -40,7 +40,7 @@ export const BaseRouter = ({ }: BaseRouterProps): JSX.Element => { // Disabling is acceptable since this is a Router component // eslint-disable-next-line custom-rules/no-navigate-useClerk - const { navigate: clerkNavigate, __internal_setComponentNavigationContext } = useClerk(); + const { navigate: clerkNavigate } = useClerk(); const [routeParts, setRouteParts] = React.useState({ path: getPath(), @@ -121,10 +121,6 @@ export const BaseRouter = ({ return internalNavRes; }; - React.useEffect(() => { - return __internal_setComponentNavigationContext?.({ basePath, navigate: baseNavigate }); - }, []); - return ( <RouteContext.Provider value={{ diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 66b1ddffe7a..3e33a9a59e8 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -433,7 +433,12 @@ export interface Clerk { */ __internal_setComponentNavigationContext: ( context: { - navigate: (toURL: URL | undefined) => Promise<unknown>; + navigate: ( + to: string, + options?: { + searchParams?: URLSearchParams; + }, + ) => Promise<unknown>; basePath: string; } | null, ) => () => void; From a6fdfd5e9c37532ae74786d12f114fefeb861eb3 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 11 Mar 2025 20:56:30 -0300 Subject: [PATCH 11/16] Remove unused `null` from type --- packages/clerk-js/src/core/clerk.ts | 20 +++++++++----------- packages/types/src/clerk.ts | 20 +++++++++----------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 936743ff582..32f0fb7ece7 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1117,17 +1117,15 @@ export class Clerk implements ClerkInterface { return unsubscribe; }; - public __internal_setComponentNavigationContext = ( - context: { - navigate: ( - to: string, - options?: { - searchParams?: URLSearchParams; - }, - ) => Promise<unknown>; - basePath: string; - } | null, - ) => { + public __internal_setComponentNavigationContext = (context: { + navigate: ( + to: string, + options?: { + searchParams?: URLSearchParams; + }, + ) => Promise<unknown>; + basePath: string; + }) => { this.#componentNavigationContext = context; return () => (this.#componentNavigationContext = null); diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 3e33a9a59e8..3a24769af92 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -431,17 +431,15 @@ export interface Clerk { * be triggered from `Clerk` methods * @internal */ - __internal_setComponentNavigationContext: ( - context: { - navigate: ( - to: string, - options?: { - searchParams?: URLSearchParams; - }, - ) => Promise<unknown>; - basePath: string; - } | null, - ) => () => void; + __internal_setComponentNavigationContext: (context: { + navigate: ( + to: string, + options?: { + searchParams?: URLSearchParams; + }, + ) => Promise<unknown>; + basePath: string; + }) => () => void; /** * Set the active session and organization explicitly. From 8f8fd26ada1199b90f3b54ebefb556f0e9a4f64a Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Tue, 11 Mar 2025 20:58:54 -0300 Subject: [PATCH 12/16] Add comment to clarify temporary `experimental.withSessionTasks` --- integration/templates/next-app-router/src/app/layout.tsx | 2 +- packages/clerk-js/src/ui/components/SignIn/SignIn.tsx | 1 + packages/clerk-js/src/ui/components/SignUp/SignUp.tsx | 1 + packages/types/src/clerk.ts | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/integration/templates/next-app-router/src/app/layout.tsx b/integration/templates/next-app-router/src/app/layout.tsx index f83c142ffcb..e598334db29 100644 --- a/integration/templates/next-app-router/src/app/layout.tsx +++ b/integration/templates/next-app-router/src/app/layout.tsx @@ -24,7 +24,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) persistClient: process.env.NEXT_PUBLIC_EXPERIMENTAL_PERSIST_CLIENT ? process.env.NEXT_PUBLIC_EXPERIMENTAL_PERSIST_CLIENT === 'true' : undefined, - // `withSessionTasks` will be removed soon in favor of checking via environment response + // `experimental.withSessionTasks` will be removed soon in favor of checking via environment response withSessionTasks: true, }} > diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index 0cbb5a25cc0..2b0368e831b 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -187,6 +187,7 @@ function SignInRoot() { */ usePreloadSignUp(signInContext.isCombinedFlow); + // `experimental.withSessionTasks` will be removed soon in favor of checking via environment response usePreloadSessionTask(signInContext.withSessionTasks); React.useEffect(() => { diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx index a3ed31c64f4..adfe0c8d4e7 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx @@ -31,6 +31,7 @@ function SignUpRoutes(): JSX.Element { const { navigate, basePath } = useRouter(); const signUpContext = useSignUpContext(); + // `experimental.withSessionTasks` will be removed soon in favor of checking via environment response usePreloadSessionTask(signUpContext.withSessionTasks); React.useEffect(() => { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 3a24769af92..e1366319454 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -818,6 +818,7 @@ export type ClerkOptions = ClerkOptionsNavigation & */ rethrowOfflineNetworkErrors: boolean; commerce: boolean; + // `experimental.withSessionTasks` will be removed soon in favor of checking via environment response withSessionTasks: boolean; }, Record<string, any> From 48179a460a4389581218802bf111fc15c1cd1111 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 12 Mar 2025 14:10:12 -0300 Subject: [PATCH 13/16] Update effect call to re-run via dependencies --- packages/clerk-js/src/ui/components/SignIn/SignIn.tsx | 2 +- packages/clerk-js/src/ui/components/SignUp/SignUp.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index 2b0368e831b..2a5895836f0 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -192,7 +192,7 @@ function SignInRoot() { React.useEffect(() => { return __internal_setComponentNavigationContext?.({ basePath, navigate }); - }, []); + }, [basePath, navigate]); return ( <SignUpContext.Provider value={normalizedSignUpContext}> diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx index adfe0c8d4e7..9e42f7efac8 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx @@ -36,7 +36,7 @@ function SignUpRoutes(): JSX.Element { React.useEffect(() => { return __internal_setComponentNavigationContext?.({ basePath, navigate }); - }, []); + }, [basePath, navigate]); return ( <Flow.Root flow='signUp'> From c95edabc666b0ad51e3bae7d2f76aef88dc26ef3 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 12 Mar 2025 15:59:13 -0300 Subject: [PATCH 14/16] Update changeset to include `clerk-react` --- .changeset/brave-pears-add.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/brave-pears-add.md b/.changeset/brave-pears-add.md index ce2ecccbfc2..74870af7af9 100644 --- a/.changeset/brave-pears-add.md +++ b/.changeset/brave-pears-add.md @@ -1,6 +1,7 @@ --- '@clerk/clerk-js': minor '@clerk/types': minor +'@clerk/clerk-react': minor --- Navigate to tasks on after sign-in/sign-up From 16d9332802a73b7b4e58f3e54996328bff3d8741 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 12 Mar 2025 18:50:44 -0300 Subject: [PATCH 15/16] Update bundlewatch based on latest rebase --- packages/clerk-js/bundlewatch.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index f62025992aa..d5015e80235 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "570kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "76kB" }, + { "path": "./dist/clerk.js", "maxSize": "572kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "78kB" }, { "path": "./dist/clerk.headless.js", "maxSize": "50KB" }, { "path": "./dist/ui-common*.js", "maxSize": "92KB" }, { "path": "./dist/vendors*.js", "maxSize": "26.5KB" }, From b10bf3fc0095a79f2d7eff18d8c4d2ba761641da Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Wed, 12 Mar 2025 19:04:45 -0300 Subject: [PATCH 16/16] Update redirect guard warning based on task URL --- packages/clerk-js/src/core/warnings.ts | 4 ++++ packages/clerk-js/src/ui/common/withRedirect.tsx | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/warnings.ts b/packages/clerk-js/src/core/warnings.ts index 1fd500a1e91..2513a4d00cc 100644 --- a/packages/clerk-js/src/core/warnings.ts +++ b/packages/clerk-js/src/core/warnings.ts @@ -16,8 +16,12 @@ const warnings = { 'The <SignUp/> and <SignIn/> components cannot render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the Home URL instead.', cannotRenderSignUpComponentWhenSessionExists: 'The <SignUp/> component cannot render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the value set in `afterSignUp` URL instead.', + cannotRenderSignUpComponentWhenTaskExists: + 'The <SignUp/> component cannot render when a user has a pending task, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the task instead.', cannotRenderSignInComponentWhenSessionExists: 'The <SignIn/> component cannot render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the `afterSignIn` URL instead.', + cannotRenderSignInComponentWhenTaskExists: + 'The <SignIn/> component cannot render when a user has a pending task, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the task instead.', cannotRenderComponentWhenUserDoesNotExist: '<UserProfile/> cannot render unless a user is signed in. Since no user is signed in, this is no-op.', cannotRenderComponentWhenOrgDoesNotExist: `<OrganizationProfile/> cannot render unless an organization is active. Since no organization is currently active, this is no-op.`, diff --git a/packages/clerk-js/src/ui/common/withRedirect.tsx b/packages/clerk-js/src/ui/common/withRedirect.tsx index 9e0f3b3deed..81336a63ec2 100644 --- a/packages/clerk-js/src/ui/common/withRedirect.tsx +++ b/packages/clerk-js/src/ui/common/withRedirect.tsx @@ -62,7 +62,9 @@ export const withRedirectToAfterSignIn = <P extends AvailableComponentProps>(Com Component, sessionExistsAndSingleSessionModeEnabled, ({ clerk }) => signInCtx.sessionTaskUrl || signInCtx.afterSignInUrl || clerk.buildAfterSignInUrl(), - warnings.cannotRenderSignInComponentWhenSessionExists, + signInCtx.sessionTaskUrl + ? warnings.cannotRenderSignInComponentWhenTaskExists + : warnings.cannotRenderSignInComponentWhenSessionExists, )(props); }; @@ -81,7 +83,9 @@ export const withRedirectToAfterSignUp = <P extends AvailableComponentProps>(Com Component, sessionExistsAndSingleSessionModeEnabled, ({ clerk }) => signUpCtx.sessionTaskUrl || signUpCtx.afterSignUpUrl || clerk.buildAfterSignUpUrl(), - warnings.cannotRenderSignUpComponentWhenSessionExists, + signUpCtx.sessionTaskUrl + ? warnings.cannotRenderSignUpComponentWhenTaskExists + : warnings.cannotRenderSignUpComponentWhenSessionExists, )(props); };