Skip to content
Closed
7 changes: 7 additions & 0 deletions .changeset/strange-eels-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/clerk-js': patch
'@clerk/clerk-react': patch
'@clerk/types': patch
---

TODO
127 changes: 127 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,117 @@ const ECMA_VERSION = 2021,
TEST_FILES = ['**/*.test.js', '**/*.test.jsx', '**/*.test.ts', '**/*.test.tsx', '**/test/**', '**/__tests__/**'],
TYPESCRIPT_FILES = ['**/*.cts', '**/*.mts', '**/*.ts', '**/*.tsx'];

const noSetActiveRedirectUrl = {
meta: {
type: 'problem',
docs: {
description: 'Disallow calling `setActive` with `{ redirectUrl }` as a parameter.',
recommended: false,
},
messages: {
noRedirectUrl: 'Calling `setActive` with `{ redirectUrl }` as an argument is not allowed.',
},
schema: [],
},
create(context) {
return {
CallExpression(node) {
// Detect direct function calls: `setActive({ redirectUrl })`
const isDirectCall = node.callee.type === 'Identifier' && node.callee.name === 'setActive';

// Detect property calls: `clerk.setActive({ redirectUrl })` or `this.setActive({ redirectUrl })`
const isObjectCall = node.callee.type === 'MemberExpression' && node.callee.property.name === 'setActive';

if (!isDirectCall && !isObjectCall) {
return; // Exit if it's not a `setActive` call
}

// Ensure the first argument is an object containing `{ redirectUrl }`
const firstArg = node.arguments[0];

if (
firstArg &&
firstArg.type === 'ObjectExpression' &&
firstArg.properties.some(prop => prop.key.name === 'redirectUrl')
) {
context.report({
node: firstArg,
messageId: 'noRedirectUrl',
});
}
},
};
},
};

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. Use `useRouter() instead`.',
},
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',
Expand Down Expand Up @@ -285,6 +396,22 @@ 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,
'no-setActive-redirectUrl': noSetActiveRedirectUrl,
},
},
},
rules: {
'custom-rules/no-navigate-useClerk': 'error',
'custom-rules/no-setActive-redirectUrl': 'error',
},
},
{
name: 'packages/expo-passkeys',
files: ['packages/expo-passkeys/src/**/*'],
Expand Down
6 changes: 4 additions & 2 deletions integration/testUtils/userProfilePageObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
52 changes: 52 additions & 0 deletions integration/tests/user-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
15 changes: 11 additions & 4 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
AuthenticateWithGoogleOneTapParams,
AuthenticateWithMetamaskParams,
AuthenticateWithOKXWalletParams,
BeforeEmitCallback,
Clerk as ClerkInterface,
ClerkAPIError,
ClerkAuthenticateWithWeb3Params,
Expand Down Expand Up @@ -100,6 +101,7 @@ import {
import { assertNoLegacyProp } from '../utils/assertNoLegacyProp';
import { memoizeListenerCallback } from '../utils/memoizeStateListenerCallback';
import { RedirectUrls } from '../utils/redirectUrls';
import { createScopedContext } from '../utils/scopedContext';
import { AuthCookieService } from './auth/AuthCookieService';
import { CaptchaHeartbeat } from './auth/CaptchaHeartbeat';
import { CLERK_SATELLITE_URL, CLERK_SUFFIXED_COOKIES, CLERK_SYNCED, ERROR_CODES } from './constants';
Expand Down Expand Up @@ -170,6 +172,8 @@ export class Clerk implements ClerkInterface {
public __internal_country?: string | null;
public telemetry: TelemetryCollector | undefined;

public __internal_setActiveContext = createScopedContext<{ beforeEmit: BeforeEmitCallback }>();

protected internal_last_error: ClerkAPIError | null = null;
// converted to protected environment to support `updateEnvironment` type assertion
protected environment?: EnvironmentResource | null;
Expand Down Expand Up @@ -906,17 +910,20 @@ 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:"/"})`',
);
await beforeEmit(newSession);
}
const __beforeEmit = beforeEmit || this.__internal_setActiveContext.get()?.beforeEmit;
if (__beforeEmit) {
beforeUnloadTracker?.startTracking();
this.#setTransitiveState();
await __beforeEmit(newSession);
beforeUnloadTracker?.stopTracking();
}

if (redirectUrl && !beforeEmit) {
if (redirectUrl && !__beforeEmit) {
beforeUnloadTracker?.startTracking();
this.#setTransitiveState();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ type DeleteUserFormProps = FormProps;
export const DeleteUserForm = withCardStateProvider((props: DeleteUserFormProps) => {
const { onReset } = props;
const card = useCardState();
const { afterSignOutUrl, afterMultiSessionSingleSignOutUrl } = useSignOutContext();
const { navigateAfterMultiSessionSingleSignOutUrl, navigateAfterSignOut } = useSignOutContext();
const { user } = useUser();
const { t } = useLocalizations();
const { otherSessions } = useMultipleSessions({ user });
const { setActive } = useClerk();
const { setActive, __internal_setActiveContext } = useClerk();
const [deleteUserWithReverification] = useReverification(() => user?.delete());

const confirmationField = useFormControl('deleteConfirmation', '', {
Expand All @@ -36,11 +36,12 @@ export const DeleteUserForm = withCardStateProvider((props: DeleteUserFormProps)

try {
await deleteUserWithReverification();
const redirectUrl = otherSessions.length === 0 ? afterSignOutUrl : afterMultiSessionSingleSignOutUrl;
const beforeEmit = otherSessions.length === 0 ? navigateAfterSignOut : navigateAfterMultiSessionSingleSignOutUrl;

return await setActive({
session: null,
redirectUrl,
return __internal_setActiveContext.run({ beforeEmit }, () => {
return setActive({
session: null,
});
});
} catch (e) {
handleError(e, [], card.setError);
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/ui/router/BaseRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export const BaseRouter = ({
urlStateParam,
children,
}: BaseRouterProps): JSX.Element => {
// Disabling is acceptable since this is a Router component
// eslint-disable-next-line custom-rules/no-navigate-useClerk
const { navigate: externalNavigate } = useClerk();

const [routeParts, setRouteParts] = React.useState({
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/ui/router/PathRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading