Skip to content

Commit c3bf51c

Browse files
committed
fix(client): remove useNavigate from AuthKitProvider to avoid SSR warning
AuthKitProvider is rendered as a Wrap component before RouterProvider context exists. Calling useNavigate() unconditionally during render triggered "useRouter must be used inside a <RouterProvider>" warnings. The navigate function was only used as a fallback in handleSignOut when no sign-out URL was returned. Replace it with window.location.href, which is appropriate for post-sign-out redirects (full page reload is desirable after session termination). Fixes #57
1 parent c6a48b8 commit c3bf51c

File tree

2 files changed

+88
-60
lines changed

2 files changed

+88
-60
lines changed

src/client/AuthKitProvider.spec.tsx

Lines changed: 80 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,6 @@ vi.mock('../server/server-functions', () => ({
1414
getSignOutUrl: vi.fn(),
1515
}));
1616

17-
// Mock TanStack Router hooks to avoid warnings
18-
vi.mock('@tanstack/react-router', () => ({
19-
useNavigate: () => vi.fn(),
20-
useLocation: () => ({ pathname: '/' }),
21-
}));
22-
2317
describe('AuthKitProvider', () => {
2418
const mockUser: User = {
2519
id: 'user_123',
@@ -41,6 +35,31 @@ describe('AuthKitProvider', () => {
4135
vi.clearAllMocks();
4236
});
4337

38+
it('renders without router context (no useNavigate SSR warning)', async () => {
39+
// Regression test for https://github.com/workos/authkit-tanstack-start/issues/57
40+
// AuthKitProvider renders as a Wrap component before RouterProvider exists.
41+
// It must NOT call useNavigate() during render, or it would trigger:
42+
// "useRouter must be used inside a <RouterProvider>"
43+
//
44+
// Since @tanstack/react-router is not mocked here, any unconditional
45+
// useNavigate() call would throw. If this test passes, the provider
46+
// does not call useNavigate during render.
47+
const { getAuthAction } = await import('../server/actions');
48+
vi.mocked(getAuthAction).mockResolvedValue({ user: null });
49+
50+
// This will throw if AuthKitProvider calls useNavigate() unconditionally,
51+
// because there is no RouterProvider wrapping the component.
52+
await act(async () => {
53+
render(
54+
<AuthKitProvider>
55+
<div>Rendered without router</div>
56+
</AuthKitProvider>,
57+
);
58+
});
59+
60+
expect(screen.getByText('Rendered without router')).toBeDefined();
61+
});
62+
4463
it('renders children', async () => {
4564
const { getAuthAction } = await import('../server/actions');
4665

@@ -455,7 +474,7 @@ describe('AuthKitProvider', () => {
455474
});
456475
});
457476

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

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

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

471-
const TestComponent = () => {
472-
const { signOut: handleSignOut } = useAuth();
473-
return <button onClick={() => handleSignOut({ returnTo: '/login' })}>Sign Out</button>;
474-
};
493+
try {
494+
const TestComponent = () => {
495+
const { signOut: handleSignOut } = useAuth();
496+
return <button onClick={() => handleSignOut({ returnTo: '/login' })}>Sign Out</button>;
497+
};
475498

476-
render(
477-
<AuthKitProvider>
478-
<TestComponent />
479-
</AuthKitProvider>,
480-
);
499+
render(
500+
<AuthKitProvider>
501+
<TestComponent />
502+
</AuthKitProvider>,
503+
);
481504

482-
await waitFor(() => {
483-
expect(screen.getByText('Sign Out')).toBeDefined();
484-
});
505+
await waitFor(() => {
506+
expect(screen.getByText('Sign Out')).toBeDefined();
507+
});
485508

486-
await act(async () => {
487-
fireEvent.click(screen.getByText('Sign Out'));
488-
});
509+
await act(async () => {
510+
fireEvent.click(screen.getByText('Sign Out'));
511+
});
489512

490-
expect(mockNavigate).toHaveBeenCalledWith({ to: '/login' });
513+
expect(window.location.href).toBe('/login');
514+
} finally {
515+
Object.defineProperty(window, 'location', { value: originalLocation, writable: true });
516+
}
491517
});
492518

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

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

504-
const TestComponent = () => {
505-
const { signOut: handleSignOut } = useAuth();
506-
return <button onClick={() => handleSignOut()}>Sign Out</button>;
507-
};
533+
try {
534+
const TestComponent = () => {
535+
const { signOut: handleSignOut } = useAuth();
536+
return <button onClick={() => handleSignOut()}>Sign Out</button>;
537+
};
508538

509-
render(
510-
<AuthKitProvider>
511-
<TestComponent />
512-
</AuthKitProvider>,
513-
);
539+
render(
540+
<AuthKitProvider>
541+
<TestComponent />
542+
</AuthKitProvider>,
543+
);
514544

515-
await waitFor(() => {
516-
expect(screen.getByText('Sign Out')).toBeDefined();
517-
});
545+
await waitFor(() => {
546+
expect(screen.getByText('Sign Out')).toBeDefined();
547+
});
518548

519-
await act(async () => {
520-
fireEvent.click(screen.getByText('Sign Out'));
521-
});
549+
await act(async () => {
550+
fireEvent.click(screen.getByText('Sign Out'));
551+
});
522552

523-
// Default returnTo is '/'
524-
expect(getSignOutUrl).toHaveBeenCalledWith({ data: { returnTo: '/' } });
525-
expect(mockNavigate).toHaveBeenCalledWith({ to: '/' });
553+
// Default returnTo is '/'
554+
expect(getSignOutUrl).toHaveBeenCalledWith({ data: { returnTo: '/' } });
555+
expect(window.location.href).toBe('/');
556+
} finally {
557+
Object.defineProperty(window, 'location', { value: originalLocation, writable: true });
558+
}
526559
});
527560
});

src/client/AuthKitProvider.tsx

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
2-
import { useNavigate } from '@tanstack/react-router';
32
import { checkSessionAction, getAuthAction, refreshAuthAction, switchToOrganizationAction } from '../server/actions.js';
43
import { ClientUserInfo, NoUserInfo, getSignOutUrl } from '../server/server-functions.js';
54
import type { AuthContextType, AuthKitProviderProps } from './types.js';
@@ -22,7 +21,6 @@ const getProps = (auth: ClientUserInfo | NoUserInfo | undefined) => {
2221
};
2322

2423
export function AuthKitProvider({ children, onSessionExpired, initialAuth }: AuthKitProviderProps) {
25-
const navigate = useNavigate();
2624
const initialProps = getProps(initialAuth);
2725
const [user, setUser] = useState<User | null>(initialProps.user);
2826
const [sessionId, setSessionId] = useState<string | undefined>(initialProps.sessionId);
@@ -88,18 +86,15 @@ export function AuthKitProvider({ children, onSessionExpired, initialAuth }: Aut
8886
[],
8987
);
9088

91-
const handleSignOut = useCallback(
92-
async ({ returnTo = '/' }: { returnTo?: string } = {}) => {
93-
const result = await getSignOutUrl({ data: { returnTo } });
89+
const handleSignOut = useCallback(async ({ returnTo = '/' }: { returnTo?: string } = {}) => {
90+
const result = await getSignOutUrl({ data: { returnTo } });
9491

95-
if (result.url) {
96-
window.location.href = result.url;
97-
} else {
98-
navigate({ to: returnTo });
99-
}
100-
},
101-
[navigate],
102-
);
92+
if (result.url) {
93+
window.location.href = result.url;
94+
} else {
95+
window.location.href = returnTo;
96+
}
97+
}, []);
10398

10499
const handleSwitchToOrganization = useCallback(async (organizationId: string) => {
105100
try {

0 commit comments

Comments
 (0)