diff --git a/.changeset/thin-flies-rush.md b/.changeset/thin-flies-rush.md new file mode 100644 index 00000000000..f5d35e4a26f --- /dev/null +++ b/.changeset/thin-flies-rush.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Isolate nonce fetch in Suspense boundary for improved PPR support diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index 3177c997026..77dc12d7cac 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -9,11 +9,11 @@ import React from 'react'; import { useSafeLayoutEffect } from '../../client-boundary/hooks/useSafeLayoutEffect'; import { ClerkNextOptionsProvider, useClerkNextOptions } from '../../client-boundary/NextOptionsContext'; import type { NextClerkProviderProps } from '../../types'; -import { ClerkScripts } from '../../utils/clerk-script'; import { canUseKeyless } from '../../utils/feature-flags'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; import { RouterTelemetry } from '../../utils/router-telemetry'; import { invalidateCacheAction } from '../server-actions'; +import { ClerkScripts } from './ClerkScripts'; import { useAwaitablePush } from './useAwaitablePush'; import { useAwaitableReplace } from './useAwaitableReplace'; @@ -26,7 +26,7 @@ const LazyCreateKeylessApplication = dynamic(() => ); const NextClientClerkProvider = (props: NextClerkProviderProps) => { - const { __internal_invokeMiddlewareOnAuthStateChange = true, children } = props; + const { __internal_invokeMiddlewareOnAuthStateChange = true, __internal_scriptsSlot, children } = props; const router = useRouter(); const push = useAwaitablePush(); const replace = useAwaitableReplace(); @@ -89,7 +89,7 @@ const NextClientClerkProvider = (props: NextClerkProviderPr - + {__internal_scriptsSlot ?? } {children} diff --git a/packages/nextjs/src/app-router/client/ClerkScripts.tsx b/packages/nextjs/src/app-router/client/ClerkScripts.tsx new file mode 100644 index 00000000000..bed102c7c08 --- /dev/null +++ b/packages/nextjs/src/app-router/client/ClerkScripts.tsx @@ -0,0 +1,27 @@ +import { useClerk } from '@clerk/react'; +import React from 'react'; + +import { useClerkNextOptions } from '../../client-boundary/NextOptionsContext'; +import { ClerkScriptTags } from '../../utils/clerk-script-tags'; + +export function ClerkScripts() { + const { publishableKey, clerkJSUrl, clerkJSVersion, clerkUIUrl, nonce, prefetchUI } = useClerkNextOptions(); + const { domain, proxyUrl } = useClerk(); + + if (!publishableKey) { + return null; + } + + return ( + + ); +} diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index 1a9da428cff..cb7182d4db4 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,57 @@ 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 scriptsSlot = dynamic ? ( + + + + ) : undefined; + if (shouldRunAsKeyless) { return ( {children} ); } - return {children}; + return ( + + {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..6cb6ce82843 --- /dev/null +++ b/packages/nextjs/src/app-router/server/DynamicClerkScripts.tsx @@ -0,0 +1,60 @@ +import { headers } from 'next/headers'; +import React from 'react'; + +import { ClerkScriptTags } from '../../utils/clerk-script-tags'; +import { getScriptNonceFromHeader, isPrerenderingBailout } from './utils'; + +async function getNonce(): Promise { + try { + 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 ''; + } +} + +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/__tests__/DynamicClerkScripts.test.tsx b/packages/nextjs/src/app-router/server/__tests__/DynamicClerkScripts.test.tsx new file mode 100644 index 00000000000..e9c787bc1f8 --- /dev/null +++ b/packages/nextjs/src/app-router/server/__tests__/DynamicClerkScripts.test.tsx @@ -0,0 +1,89 @@ +import type React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DynamicClerkScripts } from '../DynamicClerkScripts'; + +vi.mock('next/headers', () => ({ + headers: vi.fn(), +})); + +import { headers } from 'next/headers'; + +const mockHeaders = headers as unknown as ReturnType; + +const render = async (element: Promise) => { + const resolved = await element; + if (!resolved) { + return ''; + } + return renderToStaticMarkup(resolved); +}; + +const defaultProps = { + publishableKey: 'pk_test_123', +}; + +describe('DynamicClerkScripts', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns null when publishableKey is empty', async () => { + const html = await render(DynamicClerkScripts({ publishableKey: '' })); + expect(html).toBe(''); + }); + + it('uses X-Nonce header when present', async () => { + mockHeaders.mockResolvedValue( + new Map([ + ['X-Nonce', 'test-nonce-123'], + ['Content-Security-Policy', ''], + ]), + ); + + const html = await render(DynamicClerkScripts(defaultProps)); + expect(html).toContain('nonce="test-nonce-123"'); + }); + + it('falls back to CSP header when X-Nonce is absent', async () => { + mockHeaders.mockResolvedValue( + new Map([ + ['X-Nonce', null], + ['Content-Security-Policy', "script-src 'nonce-csp-nonce-456'"], + ]), + ); + + const html = await render(DynamicClerkScripts(defaultProps)); + expect(html).toContain('nonce="csp-nonce-456"'); + }); + + it('renders scripts without a nonce value when neither X-Nonce nor CSP header is present', async () => { + mockHeaders.mockResolvedValue( + new Map([ + ['X-Nonce', null], + ['Content-Security-Policy', ''], + ]), + ); + + const html = await render(DynamicClerkScripts(defaultProps)); + expect(html).toContain('data-clerk-js-script'); + expect(html).not.toContain('nonce="test'); + expect(html).not.toContain('nonce="csp'); + }); + + it('rethrows prerendering bailout errors', async () => { + mockHeaders.mockRejectedValue(new Error('Dynamic server usage: headers')); + + await expect(render(DynamicClerkScripts(defaultProps))).rejects.toThrow('Dynamic server usage: headers'); + }); + + it('gracefully degrades when headers() throws a non-bailout error', async () => { + mockHeaders.mockRejectedValue(new Error('some unexpected error')); + + const html = await render(DynamicClerkScripts(defaultProps)); + expect(html).toContain('data-clerk-js-script'); + expect(html).not.toContain('nonce="test'); + expect(html).not.toContain('nonce="csp'); + }); +}); diff --git a/packages/nextjs/src/app-router/server/keyless-provider.tsx b/packages/nextjs/src/app-router/server/keyless-provider.tsx index e4a70a0490d..fe3c1b9777b 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_scriptsSlot?: React.ReactNode; }>; export const KeylessProvider = async (props: KeylessProviderProps) => { - const { rest, runningWithClaimedKeys, children } = props; + const { rest, runningWithClaimedKeys, __internal_scriptsSlot, 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_scriptsSlot={__internal_scriptsSlot} > {children} diff --git a/packages/nextjs/src/pages/ClerkProvider.tsx b/packages/nextjs/src/pages/ClerkProvider.tsx index 36c5e468bf5..5bc6c3b76f8 100644 --- a/packages/nextjs/src/pages/ClerkProvider.tsx +++ b/packages/nextjs/src/pages/ClerkProvider.tsx @@ -8,11 +8,11 @@ import React from 'react'; import { useSafeLayoutEffect } from '../client-boundary/hooks/useSafeLayoutEffect'; import { ClerkNextOptionsProvider } from '../client-boundary/NextOptionsContext'; import type { NextClerkProviderProps } from '../types'; -import { ClerkScripts } from '../utils/clerk-script'; import { invalidateNextRouterCache } from '../utils/invalidateNextRouterCache'; import { mergeNextClerkPropsWithEnv } from '../utils/mergeNextClerkPropsWithEnv'; import { removeBasePath } from '../utils/removeBasePath'; import { RouterTelemetry } from '../utils/router-telemetry'; +import { ClerkScripts } from './ClerkScripts'; setErrorThrowerOptions({ packageName: PACKAGE_NAME }); setClerkJSLoadingErrorPackageName(PACKAGE_NAME); @@ -56,7 +56,7 @@ export function ClerkProvider({ children, ...props }: NextC initialState={initialState} > - + {children} diff --git a/packages/nextjs/src/utils/clerk-script.tsx b/packages/nextjs/src/pages/ClerkScripts.tsx similarity index 66% rename from packages/nextjs/src/utils/clerk-script.tsx rename to packages/nextjs/src/pages/ClerkScripts.tsx index 6f8cec6e094..508100da3bd 100644 --- a/packages/nextjs/src/utils/clerk-script.tsx +++ b/packages/nextjs/src/pages/ClerkScripts.tsx @@ -5,39 +5,24 @@ import React from 'react'; import { useClerkNextOptions } from '../client-boundary/NextOptionsContext'; -type ClerkScriptProps = { - scriptUrl: string; - attributes: Record; - dataAttribute: string; - router: 'app' | 'pages'; -}; - -function ClerkScript(props: ClerkScriptProps) { - const { scriptUrl, attributes, dataAttribute, router } = props; - - /** - * Notes: - * `next/script` in 13.x.x when used with App Router will fail to pass any of our `data-*` attributes, resulting in errors - * Nextjs App Router will automatically move inline scripts inside `` - * Using the `nextjs/script` for App Router with the `beforeInteractive` strategy will throw an error because our custom script will be mounted outside the `html` tag. - */ - const Script = router === 'app' ? 'script' : NextScript; +function ClerkScript(props: { scriptUrl: string; attributes: Record; dataAttribute: string }) { + const { scriptUrl, attributes, dataAttribute } = props; return ( -