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..9325090e9bc 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'; @@ -15,6 +14,7 @@ import { invalidateCacheAction } from '../server-actions'; import { ClerkScripts } from './ClerkScripts'; import { useAwaitablePush } from './useAwaitablePush'; import { useAwaitableReplace } from './useAwaitableReplace'; +import { useDeferredRefresh } from './useDeferredRefresh'; /** * LazyCreateKeylessApplication should only be loaded if the conditions below are met. @@ -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 = useDeferredRefresh(); useSafeLayoutEffect(() => { window.__internal_onBeforeSetActive = intent => { @@ -71,7 +71,10 @@ 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). + // Fire-and-forget: setActive must not block on unrelated long-running transitions. + refresh(); } }; }, []); 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/useDeferredRefresh.ts b/packages/nextjs/src/app-router/client/useDeferredRefresh.ts new file mode 100644 index 00000000000..d4eb26c561b --- /dev/null +++ b/packages/nextjs/src/app-router/client/useDeferredRefresh.ts @@ -0,0 +1,55 @@ +'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 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 (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 useDeferredRefresh = (): (() => void) => { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + if (typeof window !== 'undefined') { + getClerkRefreshObject().fun = () => { + getClerkRefreshObject().pending = true; + startTransition(() => { + // Intentionally empty: used only to observe when in-flight transitions settle. + }); + }; + } + + useEffect(() => { + if (!isPending && getClerkRefreshObject().pending) { + getClerkRefreshObject().pending = false; + router.refresh(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isPending]); + + return useCallback(() => { + getClerkRefreshObject().fun?.(); + }, []); +}; diff --git a/packages/nextjs/src/global.d.ts b/packages/nextjs/src/global.d.ts index fa739c3edcf..c8a76664f1c 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?: () => void; + pending?: boolean; + }; __internal_onBeforeSetActive: (intent?: 'sign-out') => void | Promise; __internal_onAfterSetActive: () => void | Promise;