diff --git a/.changeset/quiet-rockets-invite.md b/.changeset/quiet-rockets-invite.md new file mode 100644 index 00000000000..9d432012a81 --- /dev/null +++ b/.changeset/quiet-rockets-invite.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Improve support for apps that opt-in to using Next.js new `cacheComponents` feature. diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index cb3f398fe0a..08218a4c322 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -15,7 +15,6 @@ import { canUseKeyless } from '../../utils/feature-flags'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; import { RouterTelemetry } from '../../utils/router-telemetry'; import { isNextWithUnstableServerActions } from '../../utils/sdk-versions'; -import { detectKeylessEnvDriftAction } from '../keyless-actions'; import { invalidateCacheAction } from '../server-actions'; import { useAwaitablePush } from './useAwaitablePush'; import { useAwaitableReplace } from './useAwaitableReplace'; @@ -28,6 +27,11 @@ const LazyCreateKeylessApplication = dynamic(() => import('./keyless-creator-reader.js').then(m => m.KeylessCreatorOrReader), ); +/** + * LazyKeylessDriftDetector should only be loaded if the conditions below are met. + */ +const LazyKeylessDriftDetector = dynamic(() => import('./keyless-drift-detector.js').then(m => m.KeylessDriftDetector)); + const NextClientClerkProvider = (props: NextClerkProviderProps) => { if (isNextWithUnstableServerActions) { const deprecationWarning = `Clerk:\nYour current Next.js version (${nextPackage.version}) will be deprecated in the next major release of "@clerk/nextjs". Please upgrade to next@14.1.0 or later.`; @@ -44,13 +48,6 @@ const NextClientClerkProvider = (props: NextClerkProviderProps) => { const replace = useAwaitableReplace(); const [isPending, startTransition] = useTransition(); - // Call drift detection on mount (client-side) - useSafeLayoutEffect(() => { - if (canUseKeyless) { - void detectKeylessEnvDriftAction(); - } - }, []); - // Avoid rendering nested ClerkProviders by checking for the existence of the ClerkNextOptions context provider const isNested = Boolean(useClerkNextOptions()); if (isNested) { @@ -137,12 +134,19 @@ const NextClientClerkProvider = (props: NextClerkProviderProps) => { ); }; -export const ClientClerkProvider = (props: NextClerkProviderProps & { disableKeyless?: boolean }) => { - const { children, disableKeyless = false, ...rest } = props; +export const ClientClerkProvider = ( + props: NextClerkProviderProps & { disableKeyless?: boolean; disableKeylessDriftDetection?: boolean }, +) => { + const { children, disableKeyless = false, disableKeylessDriftDetection = false, ...rest } = props; const safePublishableKey = mergeNextClerkPropsWithEnv(rest).publishableKey; if (safePublishableKey || !canUseKeyless || disableKeyless) { - return {children}; + return ( + + {!disableKeylessDriftDetection && } + {children} + + ); } return ( diff --git a/packages/nextjs/src/app-router/client/keyless-drift-detector.tsx b/packages/nextjs/src/app-router/client/keyless-drift-detector.tsx new file mode 100644 index 00000000000..cdcf47e6ab4 --- /dev/null +++ b/packages/nextjs/src/app-router/client/keyless-drift-detector.tsx @@ -0,0 +1,17 @@ +import { useSelectedLayoutSegments } from 'next/navigation'; +import { useEffect } from 'react'; + +import { canUseKeyless } from '../../utils/feature-flags'; + +export function KeylessDriftDetector() { + const segments = useSelectedLayoutSegments(); + const isNotFoundRoute = segments[0]?.startsWith('/_not-found') || false; + + useEffect(() => { + if (canUseKeyless && !isNotFoundRoute) { + void import('../keyless-actions.js').then(m => m.detectKeylessEnvDriftAction()); + } + }, [isNotFoundRoute]); + + return null; +} diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index 5517416af46..0897044b487 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -7,8 +7,10 @@ import { PromisifiedAuthProvider } from '../../client-boundary/PromisifiedAuthPr import { getDynamicAuthData } from '../../server/buildClerkProps'; import type { NextClerkProviderProps } from '../../types'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; +import { onlyTry } from '../../utils/only-try'; import { isNext13 } from '../../utils/sdk-versions'; import { ClientClerkProvider } from '../client/ClerkProvider'; +import { SuspenseWhenCached } from '../suspense-when-cached'; import { getKeylessStatus, KeylessProvider } from './keyless-provider'; import { buildRequestLike, getScriptNonceFromHeader } from './utils'; @@ -65,39 +67,48 @@ export async function ClerkProvider( ...rest, }); - const { shouldRunAsKeyless, runningWithClaimedKeys } = await getKeylessStatus(propsWithEnvs); + const { shouldRunAsKeyless, runningWithClaimedKeys, runningWithDriftedKeys } = await getKeylessStatus(propsWithEnvs); - let output: ReactNode; - - try { - const detectKeylessEnvDrift = await import('../../server/keyless-telemetry.js').then( - mod => mod.detectKeylessEnvDrift, - ); - await detectKeylessEnvDrift(); - } catch { - // ignore + if (runningWithDriftedKeys) { + onlyTry(async () => { + const detectKeylessEnvDrift = await import('../../server/keyless-telemetry.js').then( + mod => mod.detectKeylessEnvDrift, + ); + await detectKeylessEnvDrift(); + }); } + let output: ReactNode; + if (shouldRunAsKeyless) { output = ( - - {children} - + + + {children} + + ); } else { output = ( - - {children} - + + {children} + + ); } diff --git a/packages/nextjs/src/app-router/server/keyless-provider.tsx b/packages/nextjs/src/app-router/server/keyless-provider.tsx index 74e450373db..228d652f8bf 100644 --- a/packages/nextjs/src/app-router/server/keyless-provider.tsx +++ b/packages/nextjs/src/app-router/server/keyless-provider.tsx @@ -16,19 +16,29 @@ import { deleteKeylessAction } from '../keyless-actions'; export async function getKeylessStatus( params: Without, ) { - let [shouldRunAsKeyless, runningWithClaimedKeys, locallyStoredPublishableKey] = [false, false, '']; + let [shouldRunAsKeyless, runningWithClaimedKeys, runningWithDriftedKeys, locallyStoredPublishableKey] = [ + false, + false, + false, + '', + ]; if (canUseKeyless) { locallyStoredPublishableKey = await import('../../server/keyless-node.js') .then(mod => mod.safeParseClerkFile()?.publishableKey || '') .catch(() => ''); runningWithClaimedKeys = Boolean(params.publishableKey) && params.publishableKey === locallyStoredPublishableKey; + runningWithDriftedKeys = + Boolean(params.publishableKey) && + Boolean(locallyStoredPublishableKey) && + params.publishableKey !== locallyStoredPublishableKey; shouldRunAsKeyless = !params.publishableKey || runningWithClaimedKeys; } return { shouldRunAsKeyless, runningWithClaimedKeys, + runningWithDriftedKeys, }; } @@ -59,6 +69,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { nonce={await generateNonce()} initialState={await generateStatePromise()} disableKeyless + disableKeylessDriftDetection > {children} @@ -77,6 +88,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { })} nonce={await generateNonce()} initialState={await generateStatePromise()} + disableKeylessDriftDetection > {children} diff --git a/packages/nextjs/src/app-router/suspense-when-cached.tsx b/packages/nextjs/src/app-router/suspense-when-cached.tsx new file mode 100644 index 00000000000..2c01cb05ae2 --- /dev/null +++ b/packages/nextjs/src/app-router/suspense-when-cached.tsx @@ -0,0 +1,30 @@ +import { type PropsWithChildren, Suspense } from 'react'; +import React from 'react'; + +// eslint-disable-next-line turbo/no-undeclared-env-vars +const isNextCacheComponents = process.env.__NEXT_CACHE_COMPONENTS; + +/** + * Wraps the children in a Suspense component if the current environment is a Next.js cache component. + * @param children - The children to render. + * @param noopWhen - A condition to opt out of wrapping with Suspense. + */ +export const SuspenseWhenCached = ({ + children, + noopWhen = false, +}: PropsWithChildren<{ + /** + * A condition to opt out of wrapping with Suspense. + */ + noopWhen?: boolean; +}>) => { + if (!isNextCacheComponents) { + return children; + } + + if (noopWhen) { + return children; + } + + return {children}; +}; diff --git a/packages/nextjs/src/server/keyless-telemetry.ts b/packages/nextjs/src/server/keyless-telemetry.ts index 4995924255c..842e8941a6a 100644 --- a/packages/nextjs/src/server/keyless-telemetry.ts +++ b/packages/nextjs/src/server/keyless-telemetry.ts @@ -6,8 +6,7 @@ import { nodeFsOrThrow, nodePathOrThrow } from './fs/utils'; const EVENT_KEYLESS_ENV_DRIFT_DETECTED = 'KEYLESS_ENV_DRIFT_DETECTED'; const EVENT_SAMPLING_RATE = 1; // 100% sampling rate -const TELEMETRY_FLAG_FILE = '.clerk/.tmp/telemetry.json'; - +const TELEMETRY_FLAG_FILE_TEMPLATE = '.clerk/.tmp/telemetry.{pk}.log'; type EventKeylessEnvDriftPayload = { publicKeyMatch: boolean; secretKeyMatch: boolean; @@ -25,9 +24,9 @@ type EventKeylessEnvDriftPayload = { * * @returns The absolute path to the telemetry flag file in the project's .clerk/.tmp directory */ -function getTelemetryFlagFilePath(): string { +function getTelemetryFlagFilePath(pk: string): string { const path = nodePathOrThrow(); - return path.join(process.cwd(), TELEMETRY_FLAG_FILE); + return path.join(process.cwd(), TELEMETRY_FLAG_FILE_TEMPLATE.replace('{pk}', pk)); } /** @@ -41,26 +40,22 @@ function getTelemetryFlagFilePath(): string { * the event should be fired), false if the file already exists (meaning the event was * already fired) or if there was an error creating the file */ -function tryMarkTelemetryEventAsFired(): boolean { +function tryMarkTelemetryEventAsFired(pk: string): boolean { + if (!canUseKeyless) { + return false; + } try { - if (canUseKeyless) { - const { mkdirSync, writeFileSync } = nodeFsOrThrow(); - const path = nodePathOrThrow(); - const flagFilePath = getTelemetryFlagFilePath(); - const flagDirectory = path.dirname(flagFilePath); + const { mkdirSync, writeFileSync } = nodeFsOrThrow(); + const path = nodePathOrThrow(); + const flagFilePath = getTelemetryFlagFilePath(pk); + const flagDirectory = path.dirname(flagFilePath); - // Ensure the directory exists before attempting to write the file - mkdirSync(flagDirectory, { recursive: true }); + // Ensure the directory exists before attempting to write the file + mkdirSync(flagDirectory, { recursive: true }); - const flagData = { - firedAt: new Date().toISOString(), - event: EVENT_KEYLESS_ENV_DRIFT_DETECTED, - }; - writeFileSync(flagFilePath, JSON.stringify(flagData, null, 2), { flag: 'wx' }); - return true; - } else { - return false; - } + const fileContent = `Content not important. File name is the identifier for the telemetry event.`; + writeFileSync(flagFilePath, fileContent, { flag: 'wx' }); + return true; } catch (error: unknown) { if ((error as { code?: string })?.code === 'EEXIST') { return false; @@ -96,10 +91,6 @@ export async function detectKeylessEnvDrift(): Promise { if (!canUseKeyless) { return; } - // Only run on server side - if (typeof window !== 'undefined') { - return; - } try { // Dynamically import server-side dependencies to avoid client-side issues @@ -165,7 +156,7 @@ export async function detectKeylessEnvDrift(): Promise { secretKeyMatch, envVarsMissing, keylessFileHasKeys, - keylessPublishableKey: keylessFile.publishableKey ?? '', + keylessPublishableKey: keylessFile.publishableKey, envPublishableKey: envPublishableKey ?? '', }; @@ -178,7 +169,7 @@ export async function detectKeylessEnvDrift(): Promise { }, }); - const shouldFireEvent = tryMarkTelemetryEventAsFired(); + const shouldFireEvent = tryMarkTelemetryEventAsFired(keylessFile.publishableKey); if (shouldFireEvent) { // Fire drift detected event only if we successfully created the flag