diff --git a/.changeset/beige-colts-fold.md b/.changeset/beige-colts-fold.md new file mode 100644 index 00000000000..195057ddbd2 --- /dev/null +++ b/.changeset/beige-colts-fold.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-react': patch +--- + +Exclude `__internal_addNavigationListener` from `IsomorphicClerk`. diff --git a/.changeset/eighty-pigs-sniff.md b/.changeset/eighty-pigs-sniff.md new file mode 100644 index 00000000000..282fa523f34 --- /dev/null +++ b/.changeset/eighty-pigs-sniff.md @@ -0,0 +1,5 @@ +--- +'@clerk/types': minor +--- + +Introduce `__internal_addNavigationListener` method the `Clerk` singleton. diff --git a/.changeset/violet-fishes-provide.md b/.changeset/violet-fishes-provide.md new file mode 100644 index 00000000000..e12835b555c --- /dev/null +++ b/.changeset/violet-fishes-provide.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Bug fix: Close modals when calling `Clerk.navigate()` or `Clerk.setActive({redirectUrl})`. diff --git a/eslint.config.mjs b/eslint.config.mjs index 3ecda8961a9..02ce960cdbf 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,6 +19,75 @@ const ECMA_VERSION = 2021, TEST_FILES = ['**/*.test.js', '**/*.test.jsx', '**/*.test.ts', '**/*.test.tsx', '**/test/**', '**/__tests__/**'], TYPESCRIPT_FILES = ['**/*.cts', '**/*.mts', '**/*.ts', '**/*.tsx']; +const noNavigateUseClerk = { + meta: { + type: 'problem', + docs: { + description: 'Disallow any usage of `navigate` from `useClerk()`', + recommended: false, + }, + messages: { + noNavigate: + 'Usage of `navigate` from `useClerk()` is not allowed.\nUse `useRouter().navigate` to navigate in-between flows or `setActive({ redirectUrl })`.', + }, + schema: [], + }, + create(context) { + const sourceCode = context.getSourceCode(); + + return { + // Case 1: Destructuring `navigate` from `useClerk()` + VariableDeclarator(node) { + if ( + node.id.type === 'ObjectPattern' && // Checks if it's an object destructuring + node.init?.type === 'CallExpression' && + node.init.callee.name === 'useClerk' + ) { + for (const property of node.id.properties) { + if (property.type === 'Property' && property.key.name === 'navigate') { + context.report({ + node: property, + messageId: 'noNavigate', + }); + } + } + } + }, + + // Case 2 & 3: Accessing `navigate` on a variable or directly calling `useClerk().navigate` + MemberExpression(node) { + if ( + node.property.name === 'navigate' && + node.object.type === 'CallExpression' && + node.object.callee.name === 'useClerk' + ) { + // Case 3: Direct `useClerk().navigate` + context.report({ + node, + messageId: 'noNavigate', + }); + } else if (node.property.name === 'navigate' && node.object.type === 'Identifier') { + // Case 2: `clerk.navigate` where `clerk` is assigned `useClerk()` + const scope = sourceCode.scopeManager.acquire(node); + if (!scope) return; + + const variable = scope.variables.find(v => v.name === node.object.name); + + if ( + variable?.defs?.[0]?.node?.init?.type === 'CallExpression' && + variable.defs[0].node.init.callee.name === 'useClerk' + ) { + context.report({ + node, + messageId: 'noNavigate', + }); + } + } + }, + }; + }, +}; + export default tseslint.config([ { name: 'repo/ignores', @@ -285,6 +354,20 @@ export default tseslint.config([ 'react-hooks/rules-of-hooks': 'warn', }, }, + { + name: 'packages/clerk-js', + files: ['packages/clerk-js/src/ui/**/*'], + plugins: { + 'custom-rules': { + rules: { + 'no-navigate-useClerk': noNavigateUseClerk, + }, + }, + }, + rules: { + 'custom-rules/no-navigate-useClerk': 'error', + }, + }, { name: 'packages/expo-passkeys', files: ['packages/expo-passkeys/src/**/*'], diff --git a/integration/templates/react-vite/src/buttons/index.tsx b/integration/templates/react-vite/src/buttons/index.tsx new file mode 100644 index 00000000000..5aa32d433cf --- /dev/null +++ b/integration/templates/react-vite/src/buttons/index.tsx @@ -0,0 +1,37 @@ +import { SignInButton, SignUpButton } from '@clerk/clerk-react'; + +export default function Home() { + return ( +
+ + Sign in button (force) + + + + Sign in button (fallback) + + + + Sign up button (force) + + + + Sign up button (fallback) + +
+ ); +} diff --git a/integration/templates/react-vite/src/main.tsx b/integration/templates/react-vite/src/main.tsx index 15d8b3c56f4..20f64bdb9e4 100644 --- a/integration/templates/react-vite/src/main.tsx +++ b/integration/templates/react-vite/src/main.tsx @@ -18,6 +18,7 @@ import OrganizationProfile from './organization-profile'; import OrganizationList from './organization-list'; import CreateOrganization from './create-organization'; import OrganizationSwitcher from './organization-switcher'; +import Buttons from './buttons'; const Root = () => { const navigate = useNavigate(); @@ -68,6 +69,10 @@ const router = createBrowserRouter([ path: '/protected', element: , }, + { + path: '/buttons', + element: , + }, { path: '/custom-user-profile/*', element: , diff --git a/integration/testUtils/signInPageObject.ts b/integration/testUtils/signInPageObject.ts index ffee57fd46a..5c000c539e4 100644 --- a/integration/testUtils/signInPageObject.ts +++ b/integration/testUtils/signInPageObject.ts @@ -24,6 +24,11 @@ export const createSignInComponentPageObject = (testArgs: TestArgs) => { waitForMounted: (selector = '.cl-signIn-root') => { return page.waitForSelector(selector, { state: 'attached' }); }, + waitForModal: (state?: 'open' | 'closed') => { + return page.waitForSelector('.cl-modalContent:has(.cl-signIn-root)', { + state: state === 'closed' ? 'detached' : 'attached', + }); + }, setIdentifier: (val: string) => { return self.getIdentifierInput().fill(val); }, diff --git a/integration/testUtils/signUpPageObject.ts b/integration/testUtils/signUpPageObject.ts index b134a7c2188..d1038c5c9bc 100644 --- a/integration/testUtils/signUpPageObject.ts +++ b/integration/testUtils/signUpPageObject.ts @@ -27,6 +27,11 @@ export const createSignUpComponentPageObject = (testArgs: TestArgs) => { waitForMounted: (selector = '.cl-signUp-root') => { return page.waitForSelector(selector, { state: 'attached' }); }, + waitForModal: (state?: 'open' | 'closed') => { + return page.waitForSelector('.cl-modalContent:has(.cl-signUp-root)', { + state: state === 'closed' ? 'detached' : 'attached', + }); + }, signUpWithOauth: (provider: string) => { return page.getByRole('button', { name: new RegExp(`continue with ${provider}`, 'gi') }); }, diff --git a/integration/testUtils/userProfilePageObject.ts b/integration/testUtils/userProfilePageObject.ts index bf8473ed4af..ea0e1f4dea3 100644 --- a/integration/testUtils/userProfilePageObject.ts +++ b/integration/testUtils/userProfilePageObject.ts @@ -66,8 +66,10 @@ export const createUserProfileComponentPageObject = (testArgs: TestArgs) => { typeEmailAddress: (value: string) => { return page.getByLabel(/Email address/i).fill(value); }, - waitForUserProfileModal: () => { - return page.waitForSelector('.cl-modalContent > .cl-userProfile-root', { state: 'visible' }); + waitForUserProfileModal: (state?: 'open' | 'closed') => { + return page.waitForSelector('.cl-modalContent:has(.cl-userProfile-root)', { + state: state === 'closed' ? 'detached' : 'attached', + }); }, }; return self; diff --git a/integration/tests/oauth-flows.test.ts b/integration/tests/oauth-flows.test.ts index e60b611de94..45a24e70348 100644 --- a/integration/tests/oauth-flows.test.ts +++ b/integration/tests/oauth-flows.test.ts @@ -1,10 +1,10 @@ -import { test } from '@playwright/test'; import { createClerkClient } from '@clerk/backend'; +import { test } from '@playwright/test'; import { appConfigs } from '../presets'; +import { instanceKeys } from '../presets/envs'; import type { FakeUser } from '../testUtils'; import { createTestUtils, testAgainstRunningApps } from '../testUtils'; -import { instanceKeys } from '../presets/envs'; import { createUserService } from '../testUtils/usersService'; testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('oauth flows @nextjs', ({ app }) => { @@ -78,7 +78,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('oauth flo await u.page.getByText('Sign in button (force)').click(); - await u.po.signIn.waitForMounted(); + await u.po.signIn.waitForModal(); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); await u.page.getByText('Sign in to oauth-provider').waitFor(); @@ -103,7 +103,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('oauth flo await u.page.getByText('Sign up button (force)').click(); - await u.po.signUp.waitForMounted(); + await u.po.signUp.waitForModal(); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); await u.page.getByText('Sign in to oauth-provider').waitFor(); diff --git a/integration/tests/sign-in-flow.test.ts b/integration/tests/sign-in-flow.test.ts index 29243df5c45..84e01e62fed 100644 --- a/integration/tests/sign-in-flow.test.ts +++ b/integration/tests/sign-in-flow.test.ts @@ -40,6 +40,16 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign in f await u.po.expect.toBeSignedIn(); }); + test('(modal) sign in with email and instant password', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/buttons'); + await u.page.getByText('Sign in button (force)').click(); + await u.po.signIn.waitForModal(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + await u.po.signIn.waitForModal('closed'); + }); + test('sign in with email code', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); diff --git a/integration/tests/sign-in-or-up-flow.test.ts b/integration/tests/sign-in-or-up-flow.test.ts index 5c67a10db33..eac74cc6379 100644 --- a/integration/tests/sign-in-or-up-flow.test.ts +++ b/integration/tests/sign-in-or-up-flow.test.ts @@ -61,6 +61,20 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSignInOrUpFlow] })('sign- await u.po.expect.toBeSignedIn(); }); + test('(modal) sign in with email code', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/buttons'); + await u.page.getByText('Sign in button (fallback)').click(); + await u.po.signIn.waitForModal(); + await u.po.signIn.getIdentifierInput().fill(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.getUseAnotherMethodLink().click(); + await u.po.signIn.getAltMethodsEmailCodeButton().click(); + await u.po.signIn.enterTestOtpCode(); + await u.po.expect.toBeSignedIn(); + await u.po.signIn.waitForModal('closed'); + }); + test('sign in with phone number and password', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); @@ -221,6 +235,35 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSignInOrUpFlow] })('sign- await fakeUser.deleteIfExists(); }); + test('(modal) sign up with username, email, and password', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + const fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPassword: true, + withUsername: true, + }); + + await u.page.goToRelative('/buttons'); + await u.page.getByText('Sign in button (fallback)').click(); + await u.po.signIn.waitForModal(); + await u.po.signIn.setIdentifier(fakeUser.username); + await u.po.signIn.continue(); + + const prefilledUsername = u.po.signUp.getUsernameInput(); + await expect(prefilledUsername).toHaveValue(fakeUser.username); + + await u.po.signUp.setEmailAddress(fakeUser.email); + await u.po.signUp.setPassword(fakeUser.password); + await u.po.signUp.continue(); + + await u.po.signUp.enterTestOtpCode(); + + await u.po.expect.toBeSignedIn(); + await u.po.signIn.waitForModal('closed'); + + await fakeUser.deleteIfExists(); + }); + test('sign up, sign out and sign in again', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); const fakeUser = u.services.users.createFakeUser({ diff --git a/integration/tests/sign-up-flow.test.ts b/integration/tests/sign-up-flow.test.ts index cedba70f217..af9df350f4c 100644 --- a/integration/tests/sign-up-flow.test.ts +++ b/integration/tests/sign-up-flow.test.ts @@ -4,7 +4,7 @@ import { appConfigs } from '../presets'; import { createTestUtils, testAgainstRunningApps } from '../testUtils'; testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign up flow @generic @nextjs', ({ app }) => { - test.describe.configure({ mode: 'serial' }); + test.describe.configure({ mode: 'parallel' }); test.afterAll(async () => { await app.teardown(); @@ -90,6 +90,37 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign up f await fakeUser.deleteIfExists(); }); + test('(modal) can sign up with phone number', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + const fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPhoneNumber: true, + withUsername: true, + }); + + // Open modal + await u.page.goToRelative('/buttons'); + await u.page.getByText('Sign up button (fallback)').click(); + await u.po.signUp.waitForModal(); + + // Fill in sign up form + await u.po.signUp.signUp({ + email: fakeUser.email, + phoneNumber: fakeUser.phoneNumber, + password: fakeUser.password, + }); + + // Verify email + await u.po.signUp.enterTestOtpCode(); + // Verify phone number + await u.po.signUp.enterTestOtpCode(); + + // Check if user is signed in + await u.po.expect.toBeSignedIn(); + await u.po.signUp.waitForModal('closed'); + await fakeUser.deleteIfExists(); + }); + test('sign up with first name, last name, email, phone and password', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); const fakeUser = u.services.users.createFakeUser({ diff --git a/integration/tests/user-profile.test.ts b/integration/tests/user-profile.test.ts index b97174a6f79..bff3b0427d2 100644 --- a/integration/tests/user-profile.test.ts +++ b/integration/tests/user-profile.test.ts @@ -311,4 +311,56 @@ export default function Page() { expect(sessionCookieList.length).toBe(0); }); + + test('closes the modal after delete', async ({ page, context }) => { + const m = createTestUtils({ app }); + const delFakeUser = m.services.users.createFakeUser({ + withUsername: true, + fictionalEmail: true, + withPhoneNumber: true, + }); + await m.services.users.createBapiUser({ + ...delFakeUser, + username: undefined, + phoneNumber: undefined, + }); + + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: delFakeUser.email, password: delFakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToAppHome(); + + await u.po.userButton.waitForMounted(); + await u.po.userButton.toggleTrigger(); + await u.po.userButton.triggerManageAccount(); + + await u.po.userProfile.waitForUserProfileModal(); + await u.po.userProfile.switchToSecurityTab(); + + await u.page + .getByRole('button', { + name: /delete account/i, + }) + .click(); + + await u.page.locator('input[name=deleteConfirmation]').fill('Delete account'); + + await u.page + .getByRole('button', { + name: /delete account/i, + }) + .click(); + + await u.po.expect.toBeSignedOut(); + await u.po.userProfile.waitForUserProfileModal('closed'); + + await u.page.waitForAppUrl('/'); + + // Make sure that the session cookie is deleted + const sessionCookieList = (await u.page.context().cookies()).filter(cookie => cookie.name.startsWith('__session')); + expect(sessionCookieList.length).toBe(0); + }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 2bd3ccd9b63..8e70cfbbd25 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -187,6 +187,7 @@ export class Clerk implements ClerkInterface { #loaded = false; #listeners: Array<(emission: Resources) => void> = []; + #navigationListeners: Array<() => void> = []; #options: ClerkOptions = {}; #pageLifecycle: ReturnType | null = null; #touchThrottledUntil = 0; @@ -906,12 +907,12 @@ export class Clerk implements ClerkInterface { // automatic reloading when reloading shouldn't be happening. const beforeUnloadTracker = this.#options.standardBrowser ? createBeforeUnloadTracker() : undefined; if (beforeEmit) { - beforeUnloadTracker?.startTracking(); - this.#setTransitiveState(); deprecated( 'Clerk.setActive({beforeEmit})', 'Use the `redirectUrl` property instead. Example `Clerk.setActive({redirectUrl:"/"})`', ); + beforeUnloadTracker?.startTracking(); + this.#setTransitiveState(); await beforeEmit(newSession); beforeUnloadTracker?.stopTracking(); } @@ -940,7 +941,6 @@ export class Clerk implements ClerkInterface { this.#emit(); await onAfterSetActive(); - this.#resetComponentsState(); }; public addListener = (listener: ListenerCallback): UnsubscribeCallback => { @@ -962,11 +962,26 @@ export class Clerk implements ClerkInterface { return unsubscribe; }; + public __internal_addNavigationListener = (listener: () => void): UnsubscribeCallback => { + this.#navigationListeners.push(listener); + const unsubscribe = () => { + this.#navigationListeners = this.#navigationListeners.filter(l => l !== listener); + }; + return unsubscribe; + }; + public navigate = async (to: string | undefined, options?: NavigateOptions): Promise => { if (!to || !inBrowser()) { return; } + /** + * Trigger all navigation listeners. In order for modal UI components to close. + */ + setTimeout(() => { + this.#emitNavigationListeners(); + }, 0); + let toURL = new URL(to, window.location.href); if (!this.#allowedRedirectProtocols.includes(toURL.protocol)) { @@ -2043,15 +2058,14 @@ export class Clerk implements ClerkInterface { } }; - #broadcastSignOutEvent = () => { - this.#broadcastChannel?.postMessage({ type: 'signout' }); + #emitNavigationListeners = (): void => { + for (const listener of this.#navigationListeners) { + listener(); + } }; - #resetComponentsState = () => { - if (Clerk.mountComponentRenderer) { - this.closeSignUp(); - this.closeSignIn(); - } + #broadcastSignOutEvent = () => { + this.#broadcastChannel?.postMessage({ type: 'signout' }); }; #setTransitiveState = () => { diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index eac3243a9b5..bf6851fd3a4 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -463,13 +463,15 @@ const Components = (props: ComponentsProps) => { ); const mountedBlankCaptchaModal = ( + /** + * Captcha modal should not close on `Clerk.navigate()`, hence we are not passing `onExternalNavigate`. + */ componentsControls.closeModal('blankCaptcha')} - onExternalNavigate={() => componentsControls.closeModal('blankCaptcha')} startPath={buildVirtualRouterUrl({ base: '/blank-captcha', path: urlStateParam?.path })} componentName={'BlankCaptchaModal'} canCloseModal={false} diff --git a/packages/clerk-js/src/ui/lazyModules/providers.tsx b/packages/clerk-js/src/ui/lazyModules/providers.tsx index 0ce70597f17..61162e7ee32 100644 --- a/packages/clerk-js/src/ui/lazyModules/providers.tsx +++ b/packages/clerk-js/src/ui/lazyModules/providers.tsx @@ -84,7 +84,7 @@ type LazyModalRendererProps = React.PropsWithChildren< flowName?: FlowMetadata['flow']; startPath?: string; onClose?: ModalProps['handleClose']; - onExternalNavigate?: () => any; + onExternalNavigate?: () => void; modalContainerSx?: ThemableCssProp; modalContentSx?: ThemableCssProp; canCloseModal?: boolean; diff --git a/packages/clerk-js/src/ui/router/BaseRouter.tsx b/packages/clerk-js/src/ui/router/BaseRouter.tsx index 9cdafc3e581..1874116aa04 100644 --- a/packages/clerk-js/src/ui/router/BaseRouter.tsx +++ b/packages/clerk-js/src/ui/router/BaseRouter.tsx @@ -15,7 +15,6 @@ interface BaseRouterProps { getPath: () => string; getQueryString: () => string; internalNavigate: (toURL: URL, options?: NavigateOptions) => Promise | any; - onExternalNavigate?: () => any; refreshEvents?: Array; preservedParams?: string[]; urlStateParam?: { @@ -34,13 +33,14 @@ export const BaseRouter = ({ getPath, getQueryString, internalNavigate, - onExternalNavigate, refreshEvents, preservedParams, urlStateParam, children, }: BaseRouterProps): JSX.Element => { - const { navigate: externalNavigate } = useClerk(); + // Disabling is acceptable since this is a Router component + // eslint-disable-next-line custom-rules/no-navigate-useClerk + const { navigate: clerkNavigate } = useClerk(); const [routeParts, setRouteParts] = React.useState({ path: getPath(), @@ -94,11 +94,12 @@ export const BaseRouter = ({ return; } - if (toURL.origin !== window.location.origin || !toURL.pathname.startsWith('/' + basePath)) { - if (onExternalNavigate) { - onExternalNavigate(); - } - const res = await externalNavigate(toURL.href); + const isCrossOrigin = toURL.origin !== window.location.origin; + const isOutsideOfUIComponent = !toURL.pathname.startsWith('/' + basePath); + + if (isOutsideOfUIComponent || isCrossOrigin) { + const res = await clerkNavigate(toURL.href); + // TODO: Since we are closing the modal, why do we need to refresh ? wouldn't that unmount everything causing the state to refresh ? refresh(); return res; } diff --git a/packages/clerk-js/src/ui/router/PathRouter.tsx b/packages/clerk-js/src/ui/router/PathRouter.tsx index ac4c81109b3..6c1edc35045 100644 --- a/packages/clerk-js/src/ui/router/PathRouter.tsx +++ b/packages/clerk-js/src/ui/router/PathRouter.tsx @@ -12,6 +12,8 @@ interface PathRouterProps { } export const PathRouter = ({ basePath, preservedParams, children }: PathRouterProps): JSX.Element | null => { + // Disabling is acceptable since this is a Router component + // eslint-disable-next-line custom-rules/no-navigate-useClerk const { navigate } = useClerk(); const [stripped, setStripped] = React.useState(false); diff --git a/packages/clerk-js/src/ui/router/VirtualRouter.tsx b/packages/clerk-js/src/ui/router/VirtualRouter.tsx index a589a33c3cf..3b152d4fd24 100644 --- a/packages/clerk-js/src/ui/router/VirtualRouter.tsx +++ b/packages/clerk-js/src/ui/router/VirtualRouter.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import { useClerk } from '@clerk/shared/react'; +import React, { useEffect } from 'react'; import { useClerkModalStateParams } from '../hooks'; import { BaseRouter } from './BaseRouter'; @@ -7,7 +8,7 @@ export const VIRTUAL_ROUTER_BASE_PATH = 'CLERK-ROUTER/VIRTUAL'; interface VirtualRouterProps { startPath: string; preservedParams?: string[]; - onExternalNavigate?: () => any; + onExternalNavigate?: () => void; children: React.ReactNode; } @@ -17,11 +18,24 @@ export const VirtualRouter = ({ onExternalNavigate, children, }: VirtualRouterProps): JSX.Element => { + const { __internal_addNavigationListener } = useClerk(); const [currentURL, setCurrentURL] = React.useState( new URL('/' + VIRTUAL_ROUTER_BASE_PATH + startPath, window.location.origin), ); const { urlStateParam, removeQueryParam } = useClerkModalStateParams(); + useEffect(() => { + let unsubscribe = () => {}; + if (onExternalNavigate) { + unsubscribe = __internal_addNavigationListener(onExternalNavigate); + } + return () => { + unsubscribe(); + }; + // We are not expecting `onExternalNavigate` to change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + if (urlStateParam.componentName) { removeQueryParam(); } @@ -44,7 +58,6 @@ export const VirtualRouter = ({ startPath={startPath} getQueryString={getQueryString} internalNavigate={internalNavigate} - onExternalNavigate={onExternalNavigate} preservedParams={preservedParams} urlStateParam={urlStateParam} > diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index a71e7b52681..736570ae8a8 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -123,6 +123,7 @@ type IsomorphicLoadedClerk = Without< | 'client' | '__internal_getCachedResources' | '__internal_reloadInitialResources' + | '__internal_addNavigationListener' > & { // TODO: Align return type and parms handleRedirectCallback: (params: HandleOAuthCallbackParams) => void; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 8a58fffe1d0..2b0e8ff27c2 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -391,6 +391,12 @@ export interface Clerk { */ addListener: (callback: ListenerCallback) => UnsubscribeCallback; + /** + * Registers an internal listener that triggers a callback each time `Clerk.navigate` is called. + * Its purpose is to notify modal UI components when a navigation event occurs, allowing them to close if necessary. + */ + __internal_addNavigationListener: (callback: () => void) => UnsubscribeCallback; + /** * Set the active session and organization explicitly. *