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.
*