Skip to content

@clerk/nextjs with cacheComponents: after a root param change (/en → /sk), Clerk.navigate() within /sk never resolves #10539

Open

Description

@Joozty

Preliminary Checks

Reproduction

https://github.com/Joozty/clerk-nextjs-activity-repro

Publishable key

Not shared. The reproduction works with any development instance: put your own key in .env.local (see .env.example).

Description

With cacheComponents: true, a client-side navigation that changes a root param keeps the old root layout tree mounted, but hidden in <Activity>. For example, this happens when you click a link from /en to /sk with app/[lang]/layout.jsx. After that, a Clerk.navigate() call that stays in /sk never resolves. setActive() and signOut() wait for navigate(), so sign-in and sign-out can get stuck.

Steps to reproduce:

  1. Clone the reproduction and run pnpm install.
  2. Copy .env.example to .env.local. Set the publishable key of your own development instance there.
  3. Run pnpm dev and open http://localhost:3000/en.
  4. Click Test Clerk.navigate(). It shows resolved.
  5. Click the /sk link. Then click Test Clerk.navigate() again.

Expected behavior:

resolved, like in step 4.

Actual behavior:

still pending after 3 seconds. The promise never resolves. The button navigates to the current page, so the URL doesn't change.

  • pnpm build and pnpm start give the same result.
  • partialPrefetching: false gives the same result.
  • Without cacheComponents, the promise resolves. (Remove both cacheComponents and partialPrefetching from next.config.mjs.)
  • On /sk, await Clerk.navigate('/en') resolves, because it goes to the other language. It also resolves the promise that was stuck.

Why it happens:

  1. In the browser, Next.js puts the value of [lang] in the React key of the segment. So /en and /sk are two different React trees. With cacheComponents, Next.js doesn't unmount the /en tree. It hides the tree in <Activity>. Now two ClerkProviders are mounted.
  2. useInternalNavFun (packages/nextjs/src/app-router/client/useInternalNavFun.ts) saves its navigation function in window.__clerk_internal_navigations[name].fun during render.
  3. The hidden tree still renders. useRouter() from next/navigation reads LayoutRouterContext, and Next.js gives this context a new value on every navigation. So after each navigation, the hidden ClerkProvider renders after the visible one. Its function replaces the visible tree's function.
  4. The hidden tree's function starts the navigation with the hidden tree's startTransition. The promise resolves only when an effect runs: the isPending effect of the hidden tree, or the effect that runs when a ClerkProvider is shown or hidden. React doesn't run effects in a hidden tree. So the promise resolves only when a navigation shows or hides a tree, for example /sk → /en.
  5. Later calls with the same URL don't navigate at all, because pendingDestination is still that URL.

What users see:

I tested this with a real account:

  • Open /en, click /sk, and sign in with email in the modal. The sign-in gets stuck. Clerk creates the session, but the page shows no user button and no Sign in button until you reload. The modal goes back to the current page, so setActive() waits for a navigation in the same language.
  • Open /en, click /sk, click /en, and sign out. The sign-out gets stuck in the same way, because afterSignOutUrl (/en) is in the current language.
  • Open /en, click /sk, and sign out. This sign-out finishes, but only because of a second bug (clerk-js ignores new ClerkProvider options after the first load, so afterSignOutUrl keeps its first value #10538): clerk-js keeps the first afterSignOutUrl, so the sign-out goes to the other language, /en.
  • Sign-in with Google is not affected, because it comes back with a full page load.
  • With the fix below, both stuck cases finish.

Possible fix:

Save fun in a layout effect instead of during render. React doesn't run layout effects in a hidden tree. When the tree becomes visible, React runs its layout effects again. So fun always belongs to the visible tree:

// useInternalNavFun.ts
import { useSafeLayoutEffect } from '../../client-boundary/hooks/useSafeLayoutEffect';

// Use this instead of the `if (windowNav) { … }` block:
useSafeLayoutEffect(() => {
  if (windowNav) {
    getClerkNavigationObject(name).fun = (to, opts) => {
      // unchanged
    };
  }
});

The reproduction has this change as a pnpm patch. With the patch, Clerk.navigate() resolves after every language switch. The README explains how to apply the patch.

Related:

Environment

System:
    OS: macOS 27.2
    CPU: (10) arm64 Apple M4
    Memory: 73.23 MB / 32.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 24.15.0 - /Users/jozef/.volta/tools/image/node/24.15.0/bin/node
    npm: 11.12.1 - /Users/jozef/.volta/tools/image/node/24.15.0/bin/npm
    pnpm: 12.4.1 - /Users/jozef/.volta/bin/pnpm
  Browsers:
    Chrome: 156.0.8078.48
    Firefox: 159.0
    Safari: 27.2
  npmPackages:
    @clerk/nextjs: 7.9.22 => 7.9.22
    next: 16.4.3 => 16.4.3
    react: 19.3.0 => 19.3.0
    react-dom: 19.3.0 => 19.3.0
  Loaded from the Frontend API:
    @clerk/clerk-js: 6.41.0 (6.42.0 and main have the same code)
    @clerk/ui: 1.42.0

Activity

  1. added
    needs-triageA ticket that needs to be triaged by a team member
    on Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageA ticket that needs to be triaged by a team member

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions