Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-rockets-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': patch
---

Improve support for apps that opt-in to using Next.js new `cacheComponents` feature.
26 changes: 15 additions & 11 deletions packages/nextjs/src/app-router/client/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 [email protected] or later.`;
Expand All @@ -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) {
Expand Down Expand Up @@ -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 <NextClientClerkProvider {...rest}>{children}</NextClientClerkProvider>;
return (
<NextClientClerkProvider {...rest}>
{!disableKeylessDriftDetection && <LazyKeylessDriftDetector />}
{children}
</NextClientClerkProvider>
);
}

return (
Expand Down
17 changes: 17 additions & 0 deletions packages/nextjs/src/app-router/client/keyless-drift-detector.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +1 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add the 'use client'; directive

This component uses client-only hooks (useSelectedLayoutSegments, useEffect), but without the 'use client'; directive Next.js will treat it as a server component and fail at build/runtime with “Hooks can only be used in a Client Component” errors. Please add the directive so the file is compiled as a client component.

+'use client';
+
 import { useSelectedLayoutSegments } from 'next/navigation';
 import { useEffect } from 'react';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
'use client';
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;
}
🤖 Prompt for AI Agents
In packages/nextjs/src/app-router/client/keyless-drift-detector.tsx around lines
1 to 17, this component uses client-only hooks but lacks the 'use client'
directive; add the exact line 'use client'; as the very first line of the file
(before any imports) so Next.js treats the module as a Client Component and the
hooks (useSelectedLayoutSegments, useEffect) run correctly.

59 changes: 35 additions & 24 deletions packages/nextjs/src/app-router/server/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
});
Comment on lines +72 to +78
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid wrapping async drift detection in onlyTry

onlyTry is designed for synchronous callbacks; wrapping an async function in it won’t catch rejected Promises. If the dynamic import or detectKeylessEnvDrift() ever rejects, you’ll surface an unhandled rejection. Please handle the Promise chain explicitly (or add an awaited try/catch) so errors stay contained.

-  if (runningWithDriftedKeys) {
-    onlyTry(async () => {
-      const detectKeylessEnvDrift = await import('../../server/keyless-telemetry.js').then(
-        mod => mod.detectKeylessEnvDrift,
-      );
-      await detectKeylessEnvDrift();
-    });
-  }
+  if (runningWithDriftedKeys) {
+    void import('../../server/keyless-telemetry.js')
+      .then(mod => mod.detectKeylessEnvDrift?.())
+      .catch(() => {});
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (runningWithDriftedKeys) {
onlyTry(async () => {
const detectKeylessEnvDrift = await import('../../server/keyless-telemetry.js').then(
mod => mod.detectKeylessEnvDrift,
);
await detectKeylessEnvDrift();
});
if (runningWithDriftedKeys) {
void import('../../server/keyless-telemetry.js')
.then(mod => mod.detectKeylessEnvDrift?.())
.catch(() => {});
}
🤖 Prompt for AI Agents
In packages/nextjs/src/app-router/server/ClerkProvider.tsx around lines 72 to
78, the code wraps an async operation in onlyTry which only handles synchronous
exceptions; this can lead to unhandled Promise rejections if the dynamic import
or detectKeylessEnvDrift() rejects. Replace the onlyTry(async () => { ... })
usage with an explicit Promise-safe pattern: perform the dynamic import and
await detectKeylessEnvDrift() inside a try/catch (or call the promise and attach
.catch()), and handle/log the error inside the catch so the rejection is
contained and won’t surface as an unhandled rejection.

}

let output: ReactNode;

if (shouldRunAsKeyless) {
output = (
<KeylessProvider
rest={propsWithEnvs}
generateNonce={generateNonce}
generateStatePromise={generateStatePromise}
runningWithClaimedKeys={runningWithClaimedKeys}
>
{children}
</KeylessProvider>
<SuspenseWhenCached>
<KeylessProvider
rest={propsWithEnvs}
generateNonce={generateNonce}
generateStatePromise={generateStatePromise}
runningWithClaimedKeys={runningWithClaimedKeys}
>
{children}
</KeylessProvider>
</SuspenseWhenCached>
);
} else {
output = (
<ClientClerkProvider
{...propsWithEnvs}
nonce={await generateNonce()}
initialState={await generateStatePromise()}
// This suspense boundary is required because `cacheComponents` does not like the fact that we await `generateNonce` even though it does not accesss runtime APIs.
<SuspenseWhenCached
// When dynamic is true, we don't want to ever wrap with Suspense, instead we should let the developer handle it.
noopWhen={dynamic}
>
{children}
</ClientClerkProvider>
<ClientClerkProvider
{...propsWithEnvs}
nonce={await generateNonce()}
initialState={await generateStatePromise()}
disableKeylessDriftDetection
>
{children}
</ClientClerkProvider>
</SuspenseWhenCached>
);
}

Expand Down
14 changes: 13 additions & 1 deletion packages/nextjs/src/app-router/server/keyless-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,29 @@ import { deleteKeylessAction } from '../keyless-actions';
export async function getKeylessStatus(
params: Without<NextClerkProviderProps, '__unstable_invokeMiddlewareOnAuthStateChange'>,
) {
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,
};
}
Comment on lines 16 to 43
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add explicit return type annotation for public API.

The getKeylessStatus function is exported and appears to be part of the public API (used by server ClerkProvider), but lacks an explicit return type annotation.

As per coding guidelines

Apply this diff to add an explicit return type:

-export async function getKeylessStatus(
+export async function getKeylessStatus(
   params: Without<NextClerkProviderProps, '__unstable_invokeMiddlewareOnAuthStateChange'>,
-) {
+): Promise<{
+  shouldRunAsKeyless: boolean;
+  runningWithClaimedKeys: boolean;
+  runningWithDriftedKeys: boolean;
+}> {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function getKeylessStatus(
params: Without<NextClerkProviderProps, '__unstable_invokeMiddlewareOnAuthStateChange'>,
) {
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,
};
}
export async function getKeylessStatus(
params: Without<NextClerkProviderProps, '__unstable_invokeMiddlewareOnAuthStateChange'>,
): Promise<{
shouldRunAsKeyless: boolean;
runningWithClaimedKeys: boolean;
runningWithDriftedKeys: boolean;
}> {
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,
};
}
🤖 Prompt for AI Agents
In packages/nextjs/src/app-router/server/keyless-provider.tsx around lines 16 to
43, the exported async function getKeylessStatus lacks an explicit return type;
update the function signature to include a concrete return type annotation such
as Promise<{ shouldRunAsKeyless: boolean; runningWithClaimedKeys: boolean;
runningWithDriftedKeys: boolean }>, leaving the implementation unchanged so the
function returns the same object but now with an explicit public API type.


Expand Down Expand Up @@ -59,6 +69,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => {
nonce={await generateNonce()}
initialState={await generateStatePromise()}
disableKeyless
disableKeylessDriftDetection
>
{children}
</ClientClerkProvider>
Expand All @@ -77,6 +88,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => {
})}
nonce={await generateNonce()}
initialState={await generateStatePromise()}
disableKeylessDriftDetection
>
{children}
</ClientClerkProvider>
Expand Down
30 changes: 30 additions & 0 deletions packages/nextjs/src/app-router/suspense-when-cached.tsx
Original file line number Diff line number Diff line change
@@ -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 <Suspense>{children}</Suspense>;
};
45 changes: 18 additions & 27 deletions packages/nextjs/src/server/keyless-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
}

/**
Expand All @@ -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;
Expand Down Expand Up @@ -96,10 +91,6 @@ export async function detectKeylessEnvDrift(): Promise<void> {
if (!canUseKeyless) {
return;
}
// Only run on server side
if (typeof window !== 'undefined') {
return;
}

try {
// Dynamically import server-side dependencies to avoid client-side issues
Expand Down Expand Up @@ -165,7 +156,7 @@ export async function detectKeylessEnvDrift(): Promise<void> {
secretKeyMatch,
envVarsMissing,
keylessFileHasKeys,
keylessPublishableKey: keylessFile.publishableKey ?? '',
keylessPublishableKey: keylessFile.publishableKey,
envPublishableKey: envPublishableKey ?? '',
};

Expand All @@ -178,7 +169,7 @@ export async function detectKeylessEnvDrift(): Promise<void> {
},
});

const shouldFireEvent = tryMarkTelemetryEventAsFired();
const shouldFireEvent = tryMarkTelemetryEventAsFired(keylessFile.publishableKey);

if (shouldFireEvent) {
// Fire drift detected event only if we successfully created the flag
Expand Down