Skip to content
61 changes: 30 additions & 31 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,43 +10,37 @@ import type {
} from '@clerk/types';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';

import { ERROR_CODES, SIGN_UP_MODES } from '@/core/constants';
import { clerkInvalidFAPIResponse } from '@/core/errors';
import type { SignInStartIdentifier } from '@/ui/common';
import { buildSSOCallbackURL, getIdentifierControlDisplayValues, groupIdentifiers } from '@/ui/common';
import { handleCombinedFlowTransfer } from '@/ui/components/SignIn/handleCombinedFlowTransfer';
import { hasMultipleEnterpriseConnections, useHandleAuthenticateWithPasskey } from '@/ui/components/SignIn/shared';
import { SignInAlternativePhoneCodePhoneNumberCard } from '@/ui/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard';
import { SignInSocialButtons } from '@/ui/components/SignIn/SignInSocialButtons';
import {
getPreferredAlternativePhoneChannel,
getPreferredAlternativePhoneChannelForCombinedFlow,
getSignUpAttributeFromIdentifier,
} from '@/ui/components/SignIn/utils';
import { useCoreSignIn, useEnvironment, useSignInContext } from '@/ui/contexts';
import { Col, descriptors, Flow, localizationKeys } from '@/ui/customizables';
import { CaptchaElement } from '@/ui/elements/CaptchaElement';
import { Card } from '@/ui/elements/Card';
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { Form } from '@/ui/elements/Form';
import { Header } from '@/ui/elements/Header';
import { LoadingCard } from '@/ui/elements/LoadingCard';
import { SocialButtonsReversibleContainerWithDivider } from '@/ui/elements/ReversibleContainer';
import { useAuthRedirect, useLoadingStatus } from '@/ui/hooks';
import { useSupportEmail } from '@/ui/hooks/useSupportEmail';
import { useRouter } from '@/ui/router';
import { handleError } from '@/ui/utils/errorHandler';
import { isMobileDevice } from '@/ui/utils/isMobileDevice';
import { signInRedirectRules } from '@/ui/utils/redirectRules';
import type { FormControlState } from '@/ui/utils/useFormControl';
import { buildRequest, useFormControl } from '@/ui/utils/useFormControl';

import { ERROR_CODES, SIGN_UP_MODES } from '../../../core/constants';
import { clerkInvalidFAPIResponse } from '../../../core/errors';
import { getClerkQueryParam, removeClerkQueryParam } from '../../../utils';
import type { SignInStartIdentifier } from '../../common';
import {
buildSSOCallbackURL,
getIdentifierControlDisplayValues,
groupIdentifiers,
withRedirectToAfterSignIn,
withRedirectToSignInTask,
} from '../../common';
import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts';
import { Col, descriptors, Flow, localizationKeys } from '../../customizables';
import { CaptchaElement } from '../../elements/CaptchaElement';
import { useLoadingStatus } from '../../hooks';
import { useSupportEmail } from '../../hooks/useSupportEmail';
import { useRouter } from '../../router';
import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer';
import { hasMultipleEnterpriseConnections, useHandleAuthenticateWithPasskey } from './shared';
import { SignInAlternativePhoneCodePhoneNumberCard } from './SignInAlternativePhoneCodePhoneNumberCard';
import { SignInSocialButtons } from './SignInSocialButtons';
import {
getPreferredAlternativePhoneChannel,
getPreferredAlternativePhoneChannelForCombinedFlow,
getSignUpAttributeFromIdentifier,
} from './utils';
import { getClerkQueryParam, removeClerkQueryParam } from '@/utils';

const useAutoFillPasskey = () => {
const [isSupported, setIsSupported] = useState(false);
Expand Down Expand Up @@ -87,6 +81,7 @@ function SignInStartInternal(): JSX.Element {
const ctx = useSignInContext();
const { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow, navigateOnSetActive } = ctx;
const supportEmail = useSupportEmail();

const identifierAttributes = useMemo<SignInStartIdentifier[]>(
() => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers),
[userSettings.enabledFirstFactorIdentifiers],
Expand Down Expand Up @@ -514,7 +509,13 @@ function SignInStartInternal(): JSX.Element {
return components[identifierField.type as keyof typeof components];
}, [identifierField.type]);

if (status.isLoading || clerkStatus === 'sign_up') {
// Handle redirect scenarios - must be after all hooks
const { isRedirecting } = useAuthRedirect({
rules: signInRedirectRules,
additionalContext: { afterSignInUrl },
});

if (isRedirecting || status.isLoading || clerkStatus === 'sign_up') {
// clerkStatus being sign_up will trigger a navigation to the sign up flow, so show a loading card instead of
// rendering the sign in flow.
return <LoadingCard />;
Expand Down Expand Up @@ -698,6 +699,4 @@ const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> })
);
};

export const SignInStart = withRedirectToSignInTask(
withRedirectToAfterSignIn(withCardStateProvider(SignInStartInternal)),
);
export const SignInStart = withCardStateProvider(SignInStartInternal);
189 changes: 189 additions & 0 deletions packages/clerk-js/src/ui/hooks/__tests__/useAuthRedirect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { Clerk, EnvironmentResource } from '@clerk/types';

import type { RedirectRule } from '../../utils/redirectRules';
import { useAuthRedirect } from '../useAuthRedirect';

// Mock dependencies
vi.mock('@clerk/shared/react', () => ({
useClerk: vi.fn(),
}));

vi.mock('../../contexts', () => ({
useEnvironment: vi.fn(),
}));

vi.mock('../../router', () => ({
useRouter: vi.fn(),
}));

vi.mock('../../utils/redirectRules', () => ({
evaluateRedirectRules: vi.fn(),
isDevelopmentMode: vi.fn(() => false),
}));

import { useClerk } from '@clerk/shared/react';

import { useEnvironment } from '../../contexts';
import { useRouter } from '../../router';
import { evaluateRedirectRules } from '../../utils/redirectRules';

describe('useAuthRedirect', () => {
const mockNavigate = vi.fn();
const mockClerk = {
isSignedIn: false,
publishableKey: 'pk_test_xxx',
} as Clerk;
const mockEnvironment = {
authConfig: { singleSessionMode: false },
} as EnvironmentResource;

beforeEach(() => {
vi.clearAllMocks();
(useClerk as any).mockReturnValue(mockClerk);
(useEnvironment as any).mockReturnValue(mockEnvironment);
(useRouter as any).mockReturnValue({
currentPath: '/sign-in',
navigate: mockNavigate,
});
});

it('returns isRedirecting: false when no rules match', () => {
(evaluateRedirectRules as any).mockReturnValue(null);

const { result } = renderHook(() =>
useAuthRedirect({
rules: [],
}),
);

expect(result.current.isRedirecting).toBe(false);
});

it('returns isRedirecting: true and calls navigate when rule matches', async () => {
const mockRule: RedirectRule = vi.fn(() => ({
destination: '/dashboard',
reason: 'Test redirect',
}));

(evaluateRedirectRules as any).mockReturnValue({
destination: '/dashboard',
reason: 'Test redirect',
});

const { result } = renderHook(() =>
useAuthRedirect({
rules: [mockRule],
}),
);

await waitFor(() => {
expect(result.current.isRedirecting).toBe(true);
});

expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
});

it('passes additional context to rules', () => {
const additionalContext = { afterSignInUrl: '/custom-dashboard' };

renderHook(() =>
useAuthRedirect({
rules: [],
additionalContext,
}),
);

expect(evaluateRedirectRules).toHaveBeenCalledWith(
[],
expect.objectContaining({
clerk: mockClerk,
currentPath: '/sign-in',
environment: mockEnvironment,
afterSignInUrl: '/custom-dashboard',
}),
false,
);
});

it('re-evaluates when isSignedIn changes', () => {
const { rerender } = renderHook(() =>
useAuthRedirect({
rules: [],
}),
);

expect(evaluateRedirectRules).toHaveBeenCalledTimes(1);

// Change isSignedIn
(useClerk as any).mockReturnValue({
...mockClerk,
isSignedIn: true,
});

rerender();

expect(evaluateRedirectRules).toHaveBeenCalledTimes(2);
});

it('re-evaluates when session count changes', () => {
const { rerender } = renderHook(() =>
useAuthRedirect({
rules: [],
}),
);

expect(evaluateRedirectRules).toHaveBeenCalledTimes(1);

// Change session count
(useClerk as any).mockReturnValue({
...mockClerk,
client: { sessions: [{ id: '1' }] },
});

rerender();

expect(evaluateRedirectRules).toHaveBeenCalledTimes(2);
});

it('re-evaluates when singleSessionMode changes', () => {
const { rerender } = renderHook(() =>
useAuthRedirect({
rules: [],
}),
);

expect(evaluateRedirectRules).toHaveBeenCalledTimes(1);

// Change singleSessionMode
(useEnvironment as any).mockReturnValue({
authConfig: { singleSessionMode: true },
});

rerender();

expect(evaluateRedirectRules).toHaveBeenCalledTimes(2);
});

it('re-evaluates when currentPath changes', () => {
const { rerender } = renderHook(() =>
useAuthRedirect({
rules: [],
}),
);

expect(evaluateRedirectRules).toHaveBeenCalledTimes(1);

// Change currentPath
(useRouter as any).mockReturnValue({
currentPath: '/sign-in/factor-one',
navigate: mockNavigate,
});

rerender();

expect(evaluateRedirectRules).toHaveBeenCalledTimes(2);
});
});
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './useAuthRedirect';
export * from './useClerkModalStateParams';
export * from './useClipboard';
export * from './useDebounce';
Expand Down
47 changes: 47 additions & 0 deletions packages/clerk-js/src/ui/hooks/useAuthRedirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useClerk } from '@clerk/shared/react';
import React from 'react';

import { useEnvironment } from '../contexts';
import { useRouter } from '../router';
import type { RedirectResult, RedirectRule } from '../utils/redirectRules';
import { evaluateRedirectRules, isDevelopmentMode } from '../utils/redirectRules';

export interface UseAuthRedirectOptions {
rules: RedirectRule[];
additionalContext?: Record<string, any>;
}

export interface UseAuthRedirectReturn {
isRedirecting: boolean;
}

/**
* Hook to handle authentication redirects based on rules
*/
export function useAuthRedirect(options: UseAuthRedirectOptions): UseAuthRedirectReturn {
const clerk = useClerk();
const environment = useEnvironment();
const { navigate, currentPath } = useRouter();
const [isRedirecting, setIsRedirecting] = React.useState(false);

React.useEffect(() => {
const context = {
clerk,
currentPath,
environment,
...options.additionalContext,
};

const result = evaluateRedirectRules(options.rules, context, isDevelopmentMode(clerk));

if (result) {
setIsRedirecting(true);
void navigate(result.destination);
} else {
setIsRedirecting(false);
}
}, [clerk.isSignedIn, clerk.client?.sessions?.length, environment.authConfig.singleSessionMode, currentPath]);

return { isRedirecting };
}

Loading
Loading