From 3611e9c928c1df4ee444a6cc3a0dca4477a283a0 Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Tue, 11 Aug 2026 18:14:35 -0500 Subject: [PATCH 1/2] fix(nextjs): defer the post-setActive refresh until router transitions settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clerk's post-auth navigation into a route whose Server Component calls redirect() wedged the App Router on the intermediate route. The refresh dispatched from __internal_onAfterSetActive landed while Next was still following the redirect, got appended behind a discarded entry in the router action queue, and never ran — leaving React suspended on its unresolved state promise. ClerkProvider now routes that refresh through useAwaitableRefresh, which starts an empty transition and dispatches router.refresh() only once isPending settles, so the refresh always reaches an idle action queue. Fixes #9405 Co-Authored-By: Claude Fable 5 --- .changeset/defer-refresh-after-set-active.md | 5 + .../src/app-router/client/ClerkProvider.tsx | 9 +- .../__tests__/useAwaitableRefresh.test.tsx | 129 ++++++++++++++++++ .../app-router/client/useAwaitableRefresh.ts | 67 +++++++++ packages/nextjs/src/global.d.ts | 4 + 5 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 .changeset/defer-refresh-after-set-active.md create mode 100644 packages/nextjs/src/app-router/client/__tests__/useAwaitableRefresh.test.tsx create mode 100644 packages/nextjs/src/app-router/client/useAwaitableRefresh.ts diff --git a/.changeset/defer-refresh-after-set-active.md b/.changeset/defer-refresh-after-set-active.md new file mode 100644 index 00000000000..134b74acd97 --- /dev/null +++ b/.changeset/defer-refresh-after-set-active.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Fix the App Router hanging on the intermediate route after Clerk's post-authentication navigation lands on a page whose Server Component calls `redirect()`. `ClerkProvider` now waits for in-flight route transitions to settle before dispatching its post-`setActive` `router.refresh()`, so the refresh is no longer lost inside Next.js' router action queue while the server redirect is being followed. diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index 109e1a38c87..9920110dc3b 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -2,7 +2,6 @@ import { InternalClerkProvider as ReactClerkProvider, type Ui } from '@clerk/react/internal'; import { InitialStateProvider } from '@clerk/shared/react'; import dynamic from 'next/dynamic'; -import { useRouter } from 'next/navigation'; import React from 'react'; import { useSafeLayoutEffect } from '../../client-boundary/hooks/useSafeLayoutEffect'; @@ -14,6 +13,7 @@ import { RouterTelemetry } from '../../utils/router-telemetry'; import { invalidateCacheAction } from '../server-actions'; import { ClerkScripts } from './ClerkScripts'; import { useAwaitablePush } from './useAwaitablePush'; +import { useAwaitableRefresh } from './useAwaitableRefresh'; import { useAwaitableReplace } from './useAwaitableReplace'; /** @@ -26,9 +26,9 @@ const LazyCreateKeylessApplication = dynamic(() => const NextClientClerkProvider = (props: NextClerkProviderProps) => { const { __internal_invokeMiddlewareOnAuthStateChange = true, __internal_scriptsSlot, children } = props; - const router = useRouter(); const push = useAwaitablePush(); const replace = useAwaitableReplace(); + const refresh = useAwaitableRefresh(); useSafeLayoutEffect(() => { window.__internal_onBeforeSetActive = intent => { @@ -71,8 +71,11 @@ const NextClientClerkProvider = (props: NextClerkProviderPr window.__internal_onAfterSetActive = () => { if (__internal_invokeMiddlewareOnAuthStateChange) { - return router.refresh(); + // Deferred until in-flight transitions settle, so the refresh is never dispatched while a + // server-redirect follow-up navigation is still pending (which wedges the App Router, #9405) + return refresh(); } + return undefined; }; }, []); diff --git a/packages/nextjs/src/app-router/client/__tests__/useAwaitableRefresh.test.tsx b/packages/nextjs/src/app-router/client/__tests__/useAwaitableRefresh.test.tsx new file mode 100644 index 00000000000..063ceaea52b --- /dev/null +++ b/packages/nextjs/src/app-router/client/__tests__/useAwaitableRefresh.test.tsx @@ -0,0 +1,129 @@ +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAwaitableRefresh } from '../useAwaitableRefresh'; + +const mockRefresh = vi.fn(); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: mockRefresh }), +})); + +let currentRefresh: (() => Promise) | undefined; + +const Harness = () => { + currentRefresh = useAwaitableRefresh(); + return null; +}; + +const refresh = () => { + if (!currentRefresh) { + throw new Error('refresh function is not initialized'); + } + return currentRefresh(); +}; + +describe('useAwaitableRefresh', () => { + beforeEach(() => { + currentRefresh = undefined; + window.__clerk_internal_refresh = undefined; + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('dispatches router.refresh once transitions settle and resolves the promise', async () => { + render(); + + let promise!: Promise; + act(() => { + promise = refresh(); + }); + + await waitFor(() => { + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + + await expect(promise).resolves.toBeUndefined(); + }); + + it('coalesces concurrent refresh calls into a single router.refresh', async () => { + render(); + + let firstPromise!: Promise; + let secondPromise!: Promise; + + act(() => { + firstPromise = refresh(); + secondPromise = refresh(); + }); + + await expect(Promise.all([firstPromise, secondPromise])).resolves.toEqual([undefined, undefined]); + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + + it('does not call router.refresh when nothing was requested', async () => { + render(); + + // Give the isPending effect a chance to run on mount + await act(async () => { + await Promise.resolve(); + }); + + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + it('refreshes and resolves a stale buffer left by a previous instance on mount', async () => { + let resolved = false; + window.__clerk_internal_refresh = { + promisesBuffer: [ + () => { + resolved = true; + }, + ], + }; + + render(); + + await waitFor(() => { + expect(mockRefresh).toHaveBeenCalledTimes(1); + expect(resolved).toBe(true); + }); + }); + + it('resolves pending promises on unmount', () => { + const { unmount } = render(); + + let resolved = false; + window.__clerk_internal_refresh!.promisesBuffer = [ + () => { + resolved = true; + }, + ]; + + unmount(); + + expect(resolved).toBe(true); + }); + + it('allows a fresh refresh after a previous flush', async () => { + render(); + + let firstPromise!: Promise; + act(() => { + firstPromise = refresh(); + }); + await expect(firstPromise).resolves.toBeUndefined(); + + let secondPromise!: Promise; + act(() => { + secondPromise = refresh(); + }); + await expect(secondPromise).resolves.toBeUndefined(); + + expect(mockRefresh).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/nextjs/src/app-router/client/useAwaitableRefresh.ts b/packages/nextjs/src/app-router/client/useAwaitableRefresh.ts new file mode 100644 index 00000000000..07750d90672 --- /dev/null +++ b/packages/nextjs/src/app-router/client/useAwaitableRefresh.ts @@ -0,0 +1,67 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useTransition } from 'react'; + +const getClerkRefreshObject = () => { + window.__clerk_internal_refresh ??= {}; + return window.__clerk_internal_refresh; +}; + +/** + * Returns an "awaitable" `router.refresh()` that waits for React's in-flight transitions to settle + * before dispatching the refresh. + * + * Dispatching a refresh synchronously after an awaitable navigation resolves can permanently wedge + * the App Router: when the pushed route's Server Component calls `redirect()`, Next follows it with + * a second navigation dispatched from its redirect boundary, and a refresh dispatched while that + * follow-up is in flight can end up appended behind a discarded entry in Next's router action + * queue. It then never runs, and the unresolved state promise it handed to React suspends the + * router forever. + * + * An empty transition started here cannot settle while another transition (such as the redirect + * follow-up navigation) is still rendering, so waiting for `isPending` to flip back guarantees the + * refresh is dispatched onto an idle action queue. + */ +export const useAwaitableRefresh = (): (() => Promise) => { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + if (typeof window !== 'undefined') { + getClerkRefreshObject().fun = () => { + return new Promise(res => { + // The buffer lives on window so a pending refresh survives ClerkProvider + // being unmounted and remounted during navigations. + const refresh = getClerkRefreshObject(); + refresh.promisesBuffer ??= []; + refresh.promisesBuffer.push(res); + startTransition(() => { + // Intentionally empty: used only to observe when in-flight transitions settle. + }); + }); + }; + } + + const flushPromises = () => { + const refresh = getClerkRefreshObject(); + refresh.promisesBuffer?.forEach(resolve => resolve()); + refresh.promisesBuffer = []; + }; + + // Resolve any pending promises on unmount so callers awaiting a refresh are never left hanging + useEffect(() => { + return flushPromises; + }, []); + + useEffect(() => { + if (!isPending && getClerkRefreshObject().promisesBuffer?.length) { + router.refresh(); + flushPromises(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isPending]); + + return useCallback(() => { + return getClerkRefreshObject().fun?.() ?? Promise.resolve(); + }, []); +}; diff --git a/packages/nextjs/src/global.d.ts b/packages/nextjs/src/global.d.ts index fa739c3edcf..5c0c81826d7 100644 --- a/packages/nextjs/src/global.d.ts +++ b/packages/nextjs/src/global.d.ts @@ -38,6 +38,10 @@ interface Window { >; __clerk_nav_await: Array<(value: void) => void>; __clerk_nav: (to: string) => Promise; + __clerk_internal_refresh?: { + fun?: () => Promise; + promisesBuffer?: Array<() => void>; + }; __internal_onBeforeSetActive: (intent?: 'sign-out') => void | Promise; __internal_onAfterSetActive: () => void | Promise; From 33d1ff561a225de216ad43f8d159aa9b729ebc8f Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Tue, 11 Aug 2026 21:27:46 -0500 Subject: [PATCH 2/2] fix(nextjs): make the deferred post-setActive refresh fire-and-forget Awaiting the deferred refresh made setActive block on unrelated long-running transitions: the empty transition used to detect settling cannot finish while an app-held transition is pending, which broke the pinned behavior that auth state changes apply immediately mid-transition (transitions.test.ts). The refresh is now requested via a window-stored pending flag and dispatched by whichever hook instance observes transitions settling, so nothing awaits it (restoring onAfterSetActive's original void contract) and a request survives ClerkProvider remounts instead of resolving without ever running. Co-Authored-By: Claude Fable 5 --- .../src/app-router/client/ClerkProvider.tsx | 10 +- .../__tests__/useAwaitableRefresh.test.tsx | 129 ------------------ .../__tests__/useDeferredRefresh.test.tsx | 118 ++++++++++++++++ ...itableRefresh.ts => useDeferredRefresh.ts} | 44 +++--- packages/nextjs/src/global.d.ts | 4 +- 5 files changed, 141 insertions(+), 164 deletions(-) delete mode 100644 packages/nextjs/src/app-router/client/__tests__/useAwaitableRefresh.test.tsx create mode 100644 packages/nextjs/src/app-router/client/__tests__/useDeferredRefresh.test.tsx rename packages/nextjs/src/app-router/client/{useAwaitableRefresh.ts => useDeferredRefresh.ts} (50%) diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index 9920110dc3b..9325090e9bc 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -13,8 +13,8 @@ import { RouterTelemetry } from '../../utils/router-telemetry'; import { invalidateCacheAction } from '../server-actions'; import { ClerkScripts } from './ClerkScripts'; import { useAwaitablePush } from './useAwaitablePush'; -import { useAwaitableRefresh } from './useAwaitableRefresh'; import { useAwaitableReplace } from './useAwaitableReplace'; +import { useDeferredRefresh } from './useDeferredRefresh'; /** * LazyCreateKeylessApplication should only be loaded if the conditions below are met. @@ -28,7 +28,7 @@ const NextClientClerkProvider = (props: NextClerkProviderPr const { __internal_invokeMiddlewareOnAuthStateChange = true, __internal_scriptsSlot, children } = props; const push = useAwaitablePush(); const replace = useAwaitableReplace(); - const refresh = useAwaitableRefresh(); + const refresh = useDeferredRefresh(); useSafeLayoutEffect(() => { window.__internal_onBeforeSetActive = intent => { @@ -72,10 +72,10 @@ const NextClientClerkProvider = (props: NextClerkProviderPr window.__internal_onAfterSetActive = () => { if (__internal_invokeMiddlewareOnAuthStateChange) { // Deferred until in-flight transitions settle, so the refresh is never dispatched while a - // server-redirect follow-up navigation is still pending (which wedges the App Router, #9405) - return refresh(); + // server-redirect follow-up navigation is still pending (which wedges the App Router, #9405). + // Fire-and-forget: setActive must not block on unrelated long-running transitions. + refresh(); } - return undefined; }; }, []); diff --git a/packages/nextjs/src/app-router/client/__tests__/useAwaitableRefresh.test.tsx b/packages/nextjs/src/app-router/client/__tests__/useAwaitableRefresh.test.tsx deleted file mode 100644 index 063ceaea52b..00000000000 --- a/packages/nextjs/src/app-router/client/__tests__/useAwaitableRefresh.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { act, cleanup, render, waitFor } from '@testing-library/react'; -import React from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { useAwaitableRefresh } from '../useAwaitableRefresh'; - -const mockRefresh = vi.fn(); - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ refresh: mockRefresh }), -})); - -let currentRefresh: (() => Promise) | undefined; - -const Harness = () => { - currentRefresh = useAwaitableRefresh(); - return null; -}; - -const refresh = () => { - if (!currentRefresh) { - throw new Error('refresh function is not initialized'); - } - return currentRefresh(); -}; - -describe('useAwaitableRefresh', () => { - beforeEach(() => { - currentRefresh = undefined; - window.__clerk_internal_refresh = undefined; - vi.clearAllMocks(); - }); - - afterEach(() => { - cleanup(); - }); - - it('dispatches router.refresh once transitions settle and resolves the promise', async () => { - render(); - - let promise!: Promise; - act(() => { - promise = refresh(); - }); - - await waitFor(() => { - expect(mockRefresh).toHaveBeenCalledTimes(1); - }); - - await expect(promise).resolves.toBeUndefined(); - }); - - it('coalesces concurrent refresh calls into a single router.refresh', async () => { - render(); - - let firstPromise!: Promise; - let secondPromise!: Promise; - - act(() => { - firstPromise = refresh(); - secondPromise = refresh(); - }); - - await expect(Promise.all([firstPromise, secondPromise])).resolves.toEqual([undefined, undefined]); - expect(mockRefresh).toHaveBeenCalledTimes(1); - }); - - it('does not call router.refresh when nothing was requested', async () => { - render(); - - // Give the isPending effect a chance to run on mount - await act(async () => { - await Promise.resolve(); - }); - - expect(mockRefresh).not.toHaveBeenCalled(); - }); - - it('refreshes and resolves a stale buffer left by a previous instance on mount', async () => { - let resolved = false; - window.__clerk_internal_refresh = { - promisesBuffer: [ - () => { - resolved = true; - }, - ], - }; - - render(); - - await waitFor(() => { - expect(mockRefresh).toHaveBeenCalledTimes(1); - expect(resolved).toBe(true); - }); - }); - - it('resolves pending promises on unmount', () => { - const { unmount } = render(); - - let resolved = false; - window.__clerk_internal_refresh!.promisesBuffer = [ - () => { - resolved = true; - }, - ]; - - unmount(); - - expect(resolved).toBe(true); - }); - - it('allows a fresh refresh after a previous flush', async () => { - render(); - - let firstPromise!: Promise; - act(() => { - firstPromise = refresh(); - }); - await expect(firstPromise).resolves.toBeUndefined(); - - let secondPromise!: Promise; - act(() => { - secondPromise = refresh(); - }); - await expect(secondPromise).resolves.toBeUndefined(); - - expect(mockRefresh).toHaveBeenCalledTimes(2); - }); -}); diff --git a/packages/nextjs/src/app-router/client/__tests__/useDeferredRefresh.test.tsx b/packages/nextjs/src/app-router/client/__tests__/useDeferredRefresh.test.tsx new file mode 100644 index 00000000000..2ccf765eb50 --- /dev/null +++ b/packages/nextjs/src/app-router/client/__tests__/useDeferredRefresh.test.tsx @@ -0,0 +1,118 @@ +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useDeferredRefresh } from '../useDeferredRefresh'; + +const mockRefresh = vi.fn(); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: mockRefresh }), +})); + +let currentRefresh: (() => void) | undefined; + +const Harness = () => { + currentRefresh = useDeferredRefresh(); + return null; +}; + +const refresh = () => { + if (!currentRefresh) { + throw new Error('refresh function is not initialized'); + } + currentRefresh(); +}; + +describe('useDeferredRefresh', () => { + beforeEach(() => { + currentRefresh = undefined; + window.__clerk_internal_refresh = undefined; + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('dispatches router.refresh once transitions settle', async () => { + render(); + + act(() => { + refresh(); + }); + + await waitFor(() => { + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + }); + + it('coalesces concurrent requests into a single router.refresh', async () => { + render(); + + act(() => { + refresh(); + refresh(); + }); + + await waitFor(() => { + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + }); + + it('does not call router.refresh when nothing was requested', async () => { + render(); + + // Give the isPending effect a chance to run on mount + await act(async () => { + await Promise.resolve(); + }); + + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + it('dispatches a refresh left pending by a previous instance on mount', async () => { + window.__clerk_internal_refresh = { pending: true }; + + render(); + + await waitFor(() => { + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + expect(window.__clerk_internal_refresh?.pending).toBe(false); + }); + + it('preserves a refresh requested after unmount for the next instance', async () => { + const { unmount } = render(); + unmount(); + + // Request while no instance is mounted (e.g. ClerkProvider remounting during a navigation) + refresh(); + expect(window.__clerk_internal_refresh?.pending).toBe(true); + expect(mockRefresh).not.toHaveBeenCalled(); + + render(); + + await waitFor(() => { + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + }); + + it('allows a fresh refresh after a previous dispatch', async () => { + render(); + + act(() => { + refresh(); + }); + await waitFor(() => { + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + + act(() => { + refresh(); + }); + await waitFor(() => { + expect(mockRefresh).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/nextjs/src/app-router/client/useAwaitableRefresh.ts b/packages/nextjs/src/app-router/client/useDeferredRefresh.ts similarity index 50% rename from packages/nextjs/src/app-router/client/useAwaitableRefresh.ts rename to packages/nextjs/src/app-router/client/useDeferredRefresh.ts index 07750d90672..d4eb26c561b 100644 --- a/packages/nextjs/src/app-router/client/useAwaitableRefresh.ts +++ b/packages/nextjs/src/app-router/client/useDeferredRefresh.ts @@ -9,59 +9,47 @@ const getClerkRefreshObject = () => { }; /** - * Returns an "awaitable" `router.refresh()` that waits for React's in-flight transitions to settle - * before dispatching the refresh. + * Returns a fire-and-forget `router.refresh()` that waits for React's in-flight transitions to + * settle before dispatching the refresh. * * Dispatching a refresh synchronously after an awaitable navigation resolves can permanently wedge * the App Router: when the pushed route's Server Component calls `redirect()`, Next follows it with * a second navigation dispatched from its redirect boundary, and a refresh dispatched while that * follow-up is in flight can end up appended behind a discarded entry in Next's router action - * queue. It then never runs, and the unresolved state promise it handed to React suspends the - * router forever. + * queue (fixed upstream in next@16.3.0, broken in 15.5.1 through 16.2.x). It then never runs, and + * the unresolved state promise it handed to React suspends the router forever. * * An empty transition started here cannot settle while another transition (such as the redirect * follow-up navigation) is still rendering, so waiting for `isPending` to flip back guarantees the * refresh is dispatched onto an idle action queue. + * + * The returned function is intentionally not awaitable: a long-running app transition (e.g. a + * suspended `startTransition` held open by userland code) delays the refresh, and callers such as + * `setActive` must not block on it. The pending request lives on `window` so it survives + * `ClerkProvider` remounts; the next mounted instance dispatches it. */ -export const useAwaitableRefresh = (): (() => Promise) => { +export const useDeferredRefresh = (): (() => void) => { const router = useRouter(); const [isPending, startTransition] = useTransition(); if (typeof window !== 'undefined') { getClerkRefreshObject().fun = () => { - return new Promise(res => { - // The buffer lives on window so a pending refresh survives ClerkProvider - // being unmounted and remounted during navigations. - const refresh = getClerkRefreshObject(); - refresh.promisesBuffer ??= []; - refresh.promisesBuffer.push(res); - startTransition(() => { - // Intentionally empty: used only to observe when in-flight transitions settle. - }); + getClerkRefreshObject().pending = true; + startTransition(() => { + // Intentionally empty: used only to observe when in-flight transitions settle. }); }; } - const flushPromises = () => { - const refresh = getClerkRefreshObject(); - refresh.promisesBuffer?.forEach(resolve => resolve()); - refresh.promisesBuffer = []; - }; - - // Resolve any pending promises on unmount so callers awaiting a refresh are never left hanging - useEffect(() => { - return flushPromises; - }, []); - useEffect(() => { - if (!isPending && getClerkRefreshObject().promisesBuffer?.length) { + if (!isPending && getClerkRefreshObject().pending) { + getClerkRefreshObject().pending = false; router.refresh(); - flushPromises(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isPending]); return useCallback(() => { - return getClerkRefreshObject().fun?.() ?? Promise.resolve(); + getClerkRefreshObject().fun?.(); }, []); }; diff --git a/packages/nextjs/src/global.d.ts b/packages/nextjs/src/global.d.ts index 5c0c81826d7..c8a76664f1c 100644 --- a/packages/nextjs/src/global.d.ts +++ b/packages/nextjs/src/global.d.ts @@ -39,8 +39,8 @@ interface Window { __clerk_nav_await: Array<(value: void) => void>; __clerk_nav: (to: string) => Promise; __clerk_internal_refresh?: { - fun?: () => Promise; - promisesBuffer?: Array<() => void>; + fun?: () => void; + pending?: boolean; }; __internal_onBeforeSetActive: (intent?: 'sign-out') => void | Promise;