diff --git a/.changeset/thin-flies-rush.md b/.changeset/thin-flies-rush.md new file mode 100644 index 00000000000..0c2730537bd --- /dev/null +++ b/.changeset/thin-flies-rush.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Isolate nonce fetch in Suspense boundary for PPR support and guard `React.cache` for non-RSC environments diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index 3177c997026..a743f73b4fa 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -26,7 +26,7 @@ const LazyCreateKeylessApplication = dynamic(() => ); const NextClientClerkProvider = (props: NextClerkProviderProps) => { - const { __internal_invokeMiddlewareOnAuthStateChange = true, children } = props; + const { __internal_invokeMiddlewareOnAuthStateChange = true, __internal_skipScripts = false, children } = props; const router = useRouter(); const push = useAwaitablePush(); const replace = useAwaitableReplace(); @@ -89,7 +89,7 @@ const NextClientClerkProvider = (props: NextClerkProviderPr - + {!__internal_skipScripts && } {children} diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index 1a9da428cff..59d4c0f5b94 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -1,14 +1,14 @@ import type { Ui } from '@clerk/react/internal'; import type { InitialState, Without } from '@clerk/shared/types'; -import { headers } from 'next/headers'; -import React from 'react'; +import React, { Suspense } from 'react'; import { getDynamicAuthData } from '../../server/buildClerkProps'; import type { NextClerkProviderProps } from '../../types'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; import { ClientClerkProvider } from '../client/ClerkProvider'; +import { DynamicClerkScripts } from './DynamicClerkScripts'; import { getKeylessStatus, KeylessProvider } from './keyless-provider'; -import { buildRequestLike, getScriptNonceFromHeader } from './utils'; +import { buildRequestLike } from './utils'; const getDynamicClerkState = React.cache(async function getDynamicClerkState() { const request = await buildRequestLike(); @@ -17,43 +17,59 @@ const getDynamicClerkState = React.cache(async function getDynamicClerkState() { return data; }); -const getNonceHeaders = React.cache(async function getNonceHeaders() { - const headersList = await headers(); - const nonce = headersList.get('X-Nonce'); - return nonce - ? nonce - : // Fallback to extracting from CSP header - getScriptNonceFromHeader(headersList.get('Content-Security-Policy') || '') || ''; -}); - export async function ClerkProvider( props: Without, '__internal_invokeMiddlewareOnAuthStateChange'>, ) { const { children, dynamic, ...rest } = props; const statePromiseOrValue = dynamic ? getDynamicClerkState() : undefined; - const noncePromiseOrValue = dynamic ? getNonceHeaders() : ''; const propsWithEnvs = mergeNextClerkPropsWithEnv({ ...rest, // Even though we always cast to InitialState here, this might still be a promise. // While not reflected in the public types, we do support this for React >= 19 for internal use. initialState: statePromiseOrValue as InitialState | undefined, - nonce: await noncePromiseOrValue, }); const { shouldRunAsKeyless, runningWithClaimedKeys } = await getKeylessStatus(propsWithEnvs); + // When dynamic mode is enabled, render scripts in a Suspense boundary to isolate + // the nonce fetching (which calls headers()) from the rest of the page. + // This allows the page to remain statically renderable / use PPR. + const dynamicScripts = dynamic ? ( + + + + ) : null; + if (shouldRunAsKeyless) { return ( + {dynamicScripts} {children} ); } - return {children}; + return ( + + {dynamicScripts} + {children} + + ); } diff --git a/packages/nextjs/src/app-router/server/DynamicClerkScripts.tsx b/packages/nextjs/src/app-router/server/DynamicClerkScripts.tsx new file mode 100644 index 00000000000..1462c3e78ee --- /dev/null +++ b/packages/nextjs/src/app-router/server/DynamicClerkScripts.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { ClerkScriptTags } from '../../utils/clerk-script-tags'; +import { getNonce } from './utils'; + +type DynamicClerkScriptsProps = { + publishableKey: string; + clerkJSUrl?: string; + clerkJSVersion?: string; + clerkUIUrl?: string; + domain?: string; + proxyUrl?: string; + prefetchUI?: boolean; +}; + +/** + * Server component that fetches nonce from headers and renders Clerk scripts. + * This component should be wrapped in a Suspense boundary to isolate the dynamic + * nonce fetching from the rest of the page, allowing static rendering/PPR to work. + */ +export async function DynamicClerkScripts(props: DynamicClerkScriptsProps) { + const { publishableKey, clerkJSUrl, clerkJSVersion, clerkUIUrl, domain, proxyUrl, prefetchUI } = props; + + if (!publishableKey) { + return null; + } + + const nonce = await getNonce(); + + return ( + + ); +} diff --git a/packages/nextjs/src/app-router/server/keyless-provider.tsx b/packages/nextjs/src/app-router/server/keyless-provider.tsx index e4a70a0490d..8c4289b8b2b 100644 --- a/packages/nextjs/src/app-router/server/keyless-provider.tsx +++ b/packages/nextjs/src/app-router/server/keyless-provider.tsx @@ -32,10 +32,11 @@ export async function getKeylessStatus( type KeylessProviderProps = PropsWithChildren<{ rest: Without; runningWithClaimedKeys: boolean; + __internal_skipScripts?: boolean; }>; export const KeylessProvider = async (props: KeylessProviderProps) => { - const { rest, runningWithClaimedKeys, children } = props; + const { rest, runningWithClaimedKeys, __internal_skipScripts, children } = props; // NOTE: Create or read keys on every render. Usually this means only on hard refresh or hard navigations. const newOrReadKeys = await import('../../server/keyless-node.js') @@ -52,6 +53,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { {children} @@ -68,6 +70,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { // Explicitly use `null` instead of `undefined` here to avoid persisting `deleteKeylessAction` during merging of options. __internal_keyless_dismissPrompt: runningWithClaimedKeys ? deleteKeylessAction : null, })} + __internal_skipScripts={__internal_skipScripts} > {children} diff --git a/packages/nextjs/src/app-router/server/utils.ts b/packages/nextjs/src/app-router/server/utils.ts index dead79a8b17..71bea824315 100644 --- a/packages/nextjs/src/app-router/server/utils.ts +++ b/packages/nextjs/src/app-router/server/utils.ts @@ -1,4 +1,5 @@ import { NextRequest } from 'next/server'; +import React from 'react'; const CLERK_USE_CACHE_MARKER = Symbol.for('clerk_use_cache_error'); @@ -151,3 +152,31 @@ export function getScriptNonceFromHeader(cspHeaderValue: string): string | undef return nonce; } + +/** + * Fetches the nonce from request headers. + * Uses React.cache to deduplicate calls within the same request. + */ +// React.cache is only available in RSC environments; provide a no-op fallback for tests/non-RSC contexts. +const reactCache = + typeof React.cache === 'function' ? React.cache : any>(fn: T): T => fn; + +export const getNonce = reactCache(async function getNonce(): Promise { + try { + // Dynamically import next/headers + // @ts-expect-error: Cannot find module 'next/headers' or its corresponding type declarations.ts(2307) + const { headers } = await import('next/headers'); + const headersList = await headers(); + const nonce = headersList.get('X-Nonce'); + return nonce + ? nonce + : // Fallback to extracting from CSP header + getScriptNonceFromHeader(headersList.get('Content-Security-Policy') || '') || ''; + } catch (e) { + if (isPrerenderingBailout(e)) { + throw e; + } + // Graceful degradation — scripts load without nonce + return ''; + } +}); diff --git a/packages/nextjs/src/types.ts b/packages/nextjs/src/types.ts index 5a43befa80c..05664757a52 100644 --- a/packages/nextjs/src/types.ts +++ b/packages/nextjs/src/types.ts @@ -23,4 +23,10 @@ export type NextClerkProviderProps = Without +