Skip to content

Commit 37a4cbb

Browse files
authored
fix(react): Fix stale SSR state during cross-tab sign-out (#7865)
1 parent ccf9b70 commit 37a4cbb

4 files changed

Lines changed: 145 additions & 4 deletions

File tree

.changeset/cozy-lemons-crash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/nextjs': patch
3+
---
4+
5+
Fix an App Router navigation edge case where duplicate in-flight redirects to the same destination could leave Clerk's awaitable navigation pending indefinitely.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { act, cleanup, render, waitFor } from '@testing-library/react';
2+
import React from 'react';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { useInternalNavFun } from '../useInternalNavFun';
6+
7+
let mockedPathname = '/';
8+
9+
vi.mock('next/navigation', () => ({
10+
usePathname: () => mockedPathname,
11+
}));
12+
13+
let currentNavigate: NavigationFunction | undefined;
14+
15+
const windowNav = window.history.pushState.bind(window.history);
16+
17+
const Harness = ({ routerNav }: { routerNav: (...args: any[]) => unknown }) => {
18+
currentNavigate = useInternalNavFun({
19+
windowNav,
20+
routerNav: routerNav as any,
21+
name: 'push',
22+
});
23+
24+
return null;
25+
};
26+
27+
const navigate = (to: string) => {
28+
if (!currentNavigate) {
29+
throw new Error('navigate function is not initialized');
30+
}
31+
32+
return currentNavigate(to, { windowNavigate: vi.fn() } as any) as Promise<void>;
33+
};
34+
35+
describe('useInternalNavFun', () => {
36+
beforeEach(() => {
37+
mockedPathname = '/protected';
38+
currentNavigate = undefined;
39+
window.__clerk_internal_navigations = {};
40+
vi.clearAllMocks();
41+
});
42+
43+
afterEach(() => {
44+
cleanup();
45+
});
46+
47+
it('dedupes duplicate in-flight pushes to the same destination', async () => {
48+
const routerNav = vi.fn();
49+
const view = render(<Harness routerNav={routerNav} />);
50+
51+
let firstPromise!: Promise<void>;
52+
let secondPromise!: Promise<void>;
53+
54+
act(() => {
55+
firstPromise = navigate('/sign-in');
56+
secondPromise = navigate('/sign-in');
57+
});
58+
59+
await waitFor(() => {
60+
expect(routerNav).toHaveBeenCalledTimes(1);
61+
});
62+
63+
// Simulate route change completion so buffered resolvers flush.
64+
mockedPathname = '/sign-in';
65+
view.rerender(<Harness routerNav={routerNav} />);
66+
67+
await expect(Promise.all([firstPromise, secondPromise])).resolves.toEqual([undefined, undefined]);
68+
});
69+
70+
it('does not dedupe when destination differs', async () => {
71+
const routerNav = vi.fn();
72+
render(<Harness routerNav={routerNav} />);
73+
74+
act(() => {
75+
void navigate('/sign-in');
76+
void navigate('/sign-up');
77+
});
78+
79+
await waitFor(() => {
80+
expect(routerNav).toHaveBeenCalledTimes(2);
81+
});
82+
83+
expect(routerNav).toHaveBeenNthCalledWith(1, '/sign-in');
84+
expect(routerNav).toHaveBeenNthCalledWith(2, '/sign-up');
85+
});
86+
87+
it('allows a fresh same-destination push after flush', async () => {
88+
const routerNav = vi.fn();
89+
const view = render(<Harness routerNav={routerNav} />);
90+
91+
let firstPromise!: Promise<void>;
92+
let secondPromise!: Promise<void>;
93+
94+
act(() => {
95+
firstPromise = navigate('/sign-in');
96+
secondPromise = navigate('/sign-in');
97+
});
98+
99+
await waitFor(() => {
100+
expect(routerNav).toHaveBeenCalledTimes(1);
101+
});
102+
103+
mockedPathname = '/sign-in';
104+
view.rerender(<Harness routerNav={routerNav} />);
105+
106+
await expect(Promise.all([firstPromise, secondPromise])).resolves.toEqual([undefined, undefined]);
107+
108+
act(() => {
109+
void navigate('/sign-in');
110+
});
111+
112+
await waitFor(() => {
113+
expect(routerNav).toHaveBeenCalledTimes(2);
114+
});
115+
});
116+
});

packages/nextjs/src/app-router/client/useInternalNavFun.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,29 @@ export const useInternalNavFun = (props: {
2222

2323
if (windowNav) {
2424
getClerkNavigationObject(name).fun = (to, opts) => {
25+
const nav = getClerkNavigationObject(name);
26+
27+
// If a transition to this exact URL is already in-flight, avoid starting
28+
// a second one. Calling startTransition with a duplicate router.push()
29+
// can permanently stick useTransition's isPending at `true` when Next.js
30+
// deduplicates/cancels the redundant router action. Instead, add the
31+
// resolver to the shared buffer so it resolves alongside the existing
32+
// transition.
33+
if ((nav.promisesBuffer?.length ?? 0) > 0 && nav.pendingDestination === to) {
34+
return new Promise<void>(res => {
35+
nav.promisesBuffer ??= [];
36+
nav.promisesBuffer.push(res);
37+
});
38+
}
39+
40+
nav.pendingDestination = to;
41+
2542
return new Promise<void>(res => {
2643
// We need to use window to store the reference to the buffer,
2744
// as ClerkProvider might be unmounted and remounted during navigations
2845
// If we use a ref, it will be reset when ClerkProvider is unmounted
29-
getClerkNavigationObject(name).promisesBuffer ??= [];
30-
getClerkNavigationObject(name).promisesBuffer?.push(res);
46+
nav.promisesBuffer ??= [];
47+
nav.promisesBuffer.push(res);
3148
startTransition(() => {
3249
// If the navigation is internal, we should use the history API to navigate
3350
// as this is the way to perform a shallow navigation in Next.js App Router
@@ -47,8 +64,10 @@ export const useInternalNavFun = (props: {
4764
}
4865

4966
const flushPromises = () => {
50-
getClerkNavigationObject(name).promisesBuffer?.forEach(resolve => resolve());
51-
getClerkNavigationObject(name).promisesBuffer = [];
67+
const nav = getClerkNavigationObject(name);
68+
nav.promisesBuffer?.forEach(resolve => resolve());
69+
nav.promisesBuffer = [];
70+
nav.pendingDestination = undefined;
5271
};
5372

5473
// Flush any pending promises on mount/unmount

packages/nextjs/src/global.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ interface Window {
3333
{
3434
fun: NavigationFunction;
3535
promisesBuffer: Array<() => void> | undefined;
36+
pendingDestination: string | undefined;
3637
}
3738
>;
3839
__clerk_nav_await: Array<(value: void) => void>;

0 commit comments

Comments
 (0)