<OAuthConsent /> never mounts (blank page) when reached via <SignIn/>/<SignUp/> redirect_url in Next.js App Router: mountOAuthConsent silently no-ops during setActive()'s transitive state

未关闭
#9,659 1 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
3-5 天
新手友好度
52/100
Issue 类型
缺陷
描述清晰度
基本清楚
活跃度
活跃

调研方向

从 Clerk.setActive()、mountOAuthConsent 和 ClerkHostRenderer 开始,然后通过 useAwaitablePush 和 useInternalNavFun 跟踪所提到的 app-router/client/ClerkProvider.js 路径。复现文档中所述的从登录到同意的导航,并验证 OAuthConsent 会在传递状态结束后挂载,且不需要重新加载,同时确保已登出时的行为仍然正确。

由索引模型根据 Issue 内容生成。

描述

Preliminary Checks
Reproduction

The reproduction is the documented custom consent page itself (https://clerk.com/docs/nextjs/reference/components/authentication/oauth-consent) plus the default <SignIn /> page, in a Next.js App Router app. Full code is inline below; I can push it to a public repo if that helps triage.

// app/oauth-consent/page.tsx — verbatim from the docs
import { OAuthConsent, Show } from '@clerk/nextjs'

export const metadata = { referrer: 'strict-origin-when-cross-origin' }

export default function OAuthConsentPage() {
  return (
    <Show when="signed-in">
      <OAuthConsent />
    </Show>
  )
}
// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from '@clerk/nextjs'
export default function Page() { return <SignIn /> }

Clerk Dashboard → Paths → "OAuth consent" points at /oauth-consent, and an OAuth application with the consent screen enabled.

Publishable key

pk_live_Y2xlcmsuYWN0aW9uYm9vay5hcHAk

Description

Steps to reproduce:

  1. Sign out (or open a fresh browser profile).
  2. Start an OAuth authorization from any client. Clerk's authorization endpoint sends the signed-out user to the app's sign-in page with redirect_url pointing at the custom consent page, e.g. https://<app>/sign-in?redirect_url=https%3A%2F%2F<app>%2Foauth-consent%3Fclient_id%3D...%26scope%3D...%26redirect_uri%3D...%26state%3D...%26code_challenge%3D.... (Opening that URL directly reproduces it just as well.)
  3. Sign in — or sign up — on that page with any strategy (Google via /sign-in/sso-callback, password, password + MFA all behave the same).
  4. <SignIn /> completes and Clerk navigates to /oauth-consent?....

Expected behavior:

The consent screen renders.

Actual behavior:

In a production instance the page stays blank forever, with no console error (session recordings of the affected users show zero console errors). Reloading the exact same URL renders the consent screen immediately. Users who already have a session (Clerk redirects them straight to the consent page with a full page load) are not affected, which makes this look like a "new user" bug in product analytics: in our data every consent page view that immediately followed a sign-in/sign-up in the same tab got no "Allow" click (8/8, users sat on the blank page for 10–254 s, then reloaded or gave up), while every full-load arrival rendered and was allowed within 2–4 s (6/6). All sessions were Chrome/Edge on desktop.

Root cause (from reading the shipped bundles):

This is an interaction between four pieces that are all inside the SDK:

  1. Clerk.setActive() (clerk-js) enters its transitive state before navigating — this.session = undefined; this.organization = undefined; this.user = undefined + emit — then await this.navigate(redirectUrl), and only restores the accessors (#setAccessors) after that promise resolves.

  2. In @clerk/nextjs (App Router) navigate() for a same-origin URL is the injected routerPush (app-router/client/ClerkProvider.jsuseAwaitablePushuseInternalNavFun), i.e. a client-side router.push inside startTransition, and the promise only resolves in a useEffect once isPending flips back to false — that is, after the new route has committed. So during the commit of /oauth-consent, clerk.user === undefined.

  3. @clerk/react's ClerkHostRenderer calls mount() exactly once in componentDidMount; componentDidUpdate only forwards updateProps. Nothing re-mounts when user later becomes available.

  4. Clerk.mountOAuthConsent (added in #8335, clerk-js ≥ 6.7.3) starts with:

    if (!this.user) {
      if (this.#instanceType === 'development') throw new ClerkRuntimeError(warnings.cannotRenderOAuthConsentComponentWhenUserDoesNotExist, ...);
      return; // production: silent no-op, never retried
    }
    

    Verified in the bundle served from the CDN today (@clerk/clerk-js@6.31.0, dist/clerk.browser.js).

The docs example makes it worse, not better: in a server component import { Show } from '@clerk/nextjs' resolves through the #componentsreact-server condition to app-router/server/controlComponents.js, whose Show decides with server-side auth() and puts <OAuthConsent /> straight into the RSC payload — there is no client-side isLoaded gate at all, so the component mounts at commit time, inside the transitive window, and mountOAuthConsent bails.

Net effect: for any router-integrated SDK, the documented sign-in → consent hand-off is a soft navigation that mounts <OAuthConsent /> while clerk.user is undefined, and production swallows it. Full-page loads (already-signed-in users, reloads, cross-origin client/touch redirects) initialize clerk-js from cookies before any mount, which is why they work.

Workaround we shipped: render <OAuthConsent /> from a 'use client' component gated on useAuth():

'use client'
import { OAuthConsent, useAuth } from '@clerk/nextjs'

export function OAuthConsentCard() {
  const { isLoaded, userId } = useAuth()
  if (!isLoaded || !userId) return <Spinner />   // isLoaded is false during the transitive state
  return <OAuthConsent fallback={<Spinner />} />
}

useAuth() reports isLoaded: false while sessionId/userId are undefined, so the mount is deferred until #setAccessors runs.

Suggested fixes (any one of them is enough):

  • mountOAuthConsent should treat user === undefined (transitive) differently from user === null (signed out) — e.g. queue the node the way premountOAuthConsentNodes does and mount once the session is restored — instead of returning permanently.
  • Or have ClerkHostRenderer / withClerk re-attempt the mount when the user becomes available.
  • Or restore the accessors before resolving the router navigation in setActive().
  • At minimum, log a warning in production instead of a silent return — this failed for months without a single error anywhere.
  • The docs example for the custom consent page should gate on the client-side isLoaded (or use <ClerkLoaded>) rather than the server Show.
Environment
System:
  OS: macOS 26.3
  CPU: (10) arm64 Apple M5
Binaries:
  Node: 25.8.1
  npm: 11.11.0
  pnpm: 10.24.0
Browsers:
  Chrome: 152.0.7977.66
  Safari: 26.3
npmPackages:
  @clerk/nextjs: ^7.8.2 => 7.8.2
  @clerk/react: 6.14.7 (via @clerk/nextjs)
  @clerk/shared: 4.30.1
  @clerk/ui: ^1.30.8 => 1.30.8
  @clerk/localizations: ^4.15.7
  next: ^16.3.4
  react: ^19.2.4
  react-dom: ^19.2.4
Runtime (loaded from the Clerk CDN via the frontend API host):
  @clerk/clerk-js: 6.31.0 (npm/@clerk/clerk-js@6 → 6.31.0)
  @clerk/ui: npm/@clerk/ui@1
主要语言
TypeScript
星标
1.8k
派生
472
平均合并
2 天 15 小时
30 天内合并 PR
193

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

clerk/javascript 的其他 Issue

查看 clerk/javascript 的全部 Issue

相似的 Issue

更多 TypeScript Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。