Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 80 additions & 47 deletions src/client/AuthKitProvider.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,6 @@ vi.mock('../server/server-functions', () => ({
getSignOutUrl: vi.fn(),
}));

// Mock TanStack Router hooks to avoid warnings
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
useLocation: () => ({ pathname: '/' }),
}));

describe('AuthKitProvider', () => {
const mockUser: User = {
id: 'user_123',
Expand All @@ -41,6 +35,31 @@ describe('AuthKitProvider', () => {
vi.clearAllMocks();
});

it('renders without router context (no useNavigate SSR warning)', async () => {
// Regression test for https://github.com/workos/authkit-tanstack-start/issues/57
// AuthKitProvider renders as a Wrap component before RouterProvider exists.
// It must NOT call useNavigate() during render, or it would trigger:
// "useRouter must be used inside a <RouterProvider>"
//
// Since @tanstack/react-router is not mocked here, any unconditional
// useNavigate() call would throw. If this test passes, the provider
// does not call useNavigate during render.
const { getAuthAction } = await import('../server/actions');
vi.mocked(getAuthAction).mockResolvedValue({ user: null });

// This will throw if AuthKitProvider calls useNavigate() unconditionally,
// because there is no RouterProvider wrapping the component.
await act(async () => {
render(
<AuthKitProvider>
<div>Rendered without router</div>
</AuthKitProvider>,
);
});

expect(screen.getByText('Rendered without router')).toBeDefined();
});

it('renders children', async () => {
const { getAuthAction } = await import('../server/actions');

Expand Down Expand Up @@ -455,7 +474,7 @@ describe('AuthKitProvider', () => {
});
});

it('handles signOut when no session exists (navigates to returnTo)', async () => {
it('handles signOut when no session exists (navigates to returnTo via location)', async () => {
const { getAuthAction } = await import('../server/actions');
const { getSignOutUrl } = await import('../server/server-functions');

Expand All @@ -464,30 +483,37 @@ describe('AuthKitProvider', () => {
// Mock getSignOutUrl to return null URL (no session to terminate)
vi.mocked(getSignOutUrl).mockResolvedValue({ url: null });

const mockNavigate = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(vi.mocked(await import('@tanstack/react-router')) as any).useNavigate = () => mockNavigate;
// Mock window.location.href
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
value: { ...originalLocation, href: '' },
writable: true,
});

const TestComponent = () => {
const { signOut: handleSignOut } = useAuth();
return <button onClick={() => handleSignOut({ returnTo: '/login' })}>Sign Out</button>;
};
try {
const TestComponent = () => {
const { signOut: handleSignOut } = useAuth();
return <button onClick={() => handleSignOut({ returnTo: '/login' })}>Sign Out</button>;
};

render(
<AuthKitProvider>
<TestComponent />
</AuthKitProvider>,
);
render(
<AuthKitProvider>
<TestComponent />
</AuthKitProvider>,
);

await waitFor(() => {
expect(screen.getByText('Sign Out')).toBeDefined();
});
await waitFor(() => {
expect(screen.getByText('Sign Out')).toBeDefined();
});

await act(async () => {
fireEvent.click(screen.getByText('Sign Out'));
});
await act(async () => {
fireEvent.click(screen.getByText('Sign Out'));
});

expect(mockNavigate).toHaveBeenCalledWith({ to: '/login' });
expect(window.location.href).toBe('/login');
} finally {
Object.defineProperty(window, 'location', { value: originalLocation, writable: true });
}
});

it('uses default returnTo when signOut called without options', async () => {
Expand All @@ -497,31 +523,38 @@ describe('AuthKitProvider', () => {
vi.mocked(getAuthAction).mockResolvedValue({ user: mockUser, sessionId: 'session_123' });
vi.mocked(getSignOutUrl).mockResolvedValue({ url: null });

const mockNavigate = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(vi.mocked(await import('@tanstack/react-router')) as any).useNavigate = () => mockNavigate;
// Mock window.location.href
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
value: { ...originalLocation, href: '' },
writable: true,
});

const TestComponent = () => {
const { signOut: handleSignOut } = useAuth();
return <button onClick={() => handleSignOut()}>Sign Out</button>;
};
try {
const TestComponent = () => {
const { signOut: handleSignOut } = useAuth();
return <button onClick={() => handleSignOut()}>Sign Out</button>;
};

render(
<AuthKitProvider>
<TestComponent />
</AuthKitProvider>,
);
render(
<AuthKitProvider>
<TestComponent />
</AuthKitProvider>,
);

await waitFor(() => {
expect(screen.getByText('Sign Out')).toBeDefined();
});
await waitFor(() => {
expect(screen.getByText('Sign Out')).toBeDefined();
});

await act(async () => {
fireEvent.click(screen.getByText('Sign Out'));
});
await act(async () => {
fireEvent.click(screen.getByText('Sign Out'));
});

// Default returnTo is '/'
expect(getSignOutUrl).toHaveBeenCalledWith({ data: { returnTo: '/' } });
expect(mockNavigate).toHaveBeenCalledWith({ to: '/' });
// Default returnTo is '/'
expect(getSignOutUrl).toHaveBeenCalledWith({ data: { returnTo: '/' } });
expect(window.location.href).toBe('/');
} finally {
Object.defineProperty(window, 'location', { value: originalLocation, writable: true });
}
});
});
21 changes: 8 additions & 13 deletions src/client/AuthKitProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { checkSessionAction, getAuthAction, refreshAuthAction, switchToOrganizationAction } from '../server/actions.js';
import { ClientUserInfo, NoUserInfo, getSignOutUrl } from '../server/server-functions.js';
import type { AuthContextType, AuthKitProviderProps } from './types.js';
Expand All @@ -22,7 +21,6 @@ const getProps = (auth: ClientUserInfo | NoUserInfo | undefined) => {
};

export function AuthKitProvider({ children, onSessionExpired, initialAuth }: AuthKitProviderProps) {
const navigate = useNavigate();
const initialProps = getProps(initialAuth);
const [user, setUser] = useState<User | null>(initialProps.user);
const [sessionId, setSessionId] = useState<string | undefined>(initialProps.sessionId);
Expand Down Expand Up @@ -88,18 +86,15 @@ export function AuthKitProvider({ children, onSessionExpired, initialAuth }: Aut
[],
);

const handleSignOut = useCallback(
async ({ returnTo = '/' }: { returnTo?: string } = {}) => {
const result = await getSignOutUrl({ data: { returnTo } });
const handleSignOut = useCallback(async ({ returnTo = '/' }: { returnTo?: string } = {}) => {
const result = await getSignOutUrl({ data: { returnTo } });

if (result.url) {
window.location.href = result.url;
} else {
navigate({ to: returnTo });
}
},
[navigate],
);
if (result.url) {
window.location.href = result.url;
} else {
window.location.href = returnTo;
}
}, []);

const handleSwitchToOrganization = useCallback(async (organizationId: string) => {
try {
Expand Down
Loading