Skip to content

Commit 6fa6c93

Browse files
committed
fix(nextjs): prevent double-wrapping of use cache errors
- Add ClerkUseCacheError class with symbol marker to identify already-formatted errors - Update auth() and currentUser() to check for ClerkUseCacheError before wrapping - Tighten isNextjsUseCacheError() regex to reduce false positives - Update clerkClient.ts to use isClerkUseCacheError
1 parent db8d6de commit 6fa6c93

5 files changed

Lines changed: 100 additions & 42 deletions

File tree

packages/nextjs/src/app-router/server/__tests__/utils.test.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import { isNextjsUseCacheError, isPrerenderingBailout } from '../utils';
3+
import { ClerkUseCacheError, isClerkUseCacheError, isNextjsUseCacheError, isPrerenderingBailout } from '../utils';
44

55
describe('isPrerenderingBailout', () => {
66
it('returns false for non-Error values', () => {
@@ -43,6 +43,40 @@ describe('isPrerenderingBailout', () => {
4343
});
4444
});
4545

46+
describe('ClerkUseCacheError', () => {
47+
it('is recognized by isClerkUseCacheError', () => {
48+
const error = new ClerkUseCacheError('Test message');
49+
expect(isClerkUseCacheError(error)).toBe(true);
50+
});
51+
52+
it('preserves original error', () => {
53+
const original = new Error('Original');
54+
const error = new ClerkUseCacheError('Wrapped', original);
55+
expect(error.originalError).toBe(original);
56+
});
57+
58+
it('has correct name', () => {
59+
const error = new ClerkUseCacheError('Test');
60+
expect(error.name).toBe('ClerkUseCacheError');
61+
});
62+
});
63+
64+
describe('isClerkUseCacheError', () => {
65+
it('returns false for regular errors', () => {
66+
expect(isClerkUseCacheError(new Error('test'))).toBe(false);
67+
});
68+
69+
it('returns false for non-Error values', () => {
70+
expect(isClerkUseCacheError(null)).toBe(false);
71+
expect(isClerkUseCacheError('string')).toBe(false);
72+
expect(isClerkUseCacheError({})).toBe(false);
73+
});
74+
75+
it('returns true for ClerkUseCacheError', () => {
76+
expect(isClerkUseCacheError(new ClerkUseCacheError('test'))).toBe(true);
77+
});
78+
});
79+
4680
describe('isNextjsUseCacheError', () => {
4781
it('returns false for non-Error values', () => {
4882
expect(isNextjsUseCacheError(null)).toBe(false);
@@ -65,9 +99,10 @@ describe('isNextjsUseCacheError', () => {
6599
expect(isNextjsUseCacheError(error)).toBe(true);
66100
});
67101

68-
it('returns true for dynamic data source cache errors', () => {
102+
it('returns false for generic "cache" mentions without specific patterns', () => {
103+
// This should NOT match to reduce false positives - requires "cache scope" not just "cache"
69104
const error = new Error('Dynamic data source accessed in cache context');
70-
expect(isNextjsUseCacheError(error)).toBe(true);
105+
expect(isNextjsUseCacheError(error)).toBe(false);
71106
});
72107

73108
it('returns false for regular prerendering bailout errors', () => {

packages/nextjs/src/app-router/server/auth.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ import { unauthorized } from '../../server/nextErrors';
1111
import type { AuthProtect } from '../../server/protect';
1212
import { createProtect } from '../../server/protect';
1313
import { decryptClerkRequestData } from '../../server/utils';
14-
import { buildRequestLike, isNextjsUseCacheError, USE_CACHE_ERROR_MESSAGE } from './utils';
14+
import {
15+
buildRequestLike,
16+
ClerkUseCacheError,
17+
isClerkUseCacheError,
18+
isNextjsUseCacheError,
19+
USE_CACHE_ERROR_MESSAGE,
20+
} from './utils';
1521

1622
/**
1723
* `Auth` object of the currently active user and the `redirectToSignIn()` method.
@@ -136,9 +142,11 @@ export const auth: AuthFn = (async (options?: AuthOptions) => {
136142

137143
return authObject;
138144
} catch (e: any) {
139-
// Catch "use cache" errors that bubble up from Next.js cache boundary
145+
if (isClerkUseCacheError(e)) {
146+
throw e;
147+
}
140148
if (isNextjsUseCacheError(e)) {
141-
throw new Error(`${USE_CACHE_ERROR_MESSAGE}\n\nOriginal error: ${e.message}`);
149+
throw new ClerkUseCacheError(`${USE_CACHE_ERROR_MESSAGE}\n\nOriginal error: ${e.message}`, e);
142150
}
143151
throw e;
144152
}

packages/nextjs/src/app-router/server/currentUser.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import type { PendingSessionOptions } from '@clerk/shared/types';
33

44
import { clerkClient } from '../../server/clerkClient';
55
import { auth } from './auth';
6-
import { isNextjsUseCacheError, USE_CACHE_ERROR_MESSAGE } from './utils';
6+
import {
7+
ClerkUseCacheError,
8+
isClerkUseCacheError,
9+
isNextjsUseCacheError,
10+
USE_CACHE_ERROR_MESSAGE,
11+
} from './utils';
712

813
type CurrentUserOptions = PendingSessionOptions;
914

@@ -40,9 +45,11 @@ export async function currentUser(opts?: CurrentUserOptions): Promise<User | nul
4045

4146
return (await clerkClient()).users.getUser(userId);
4247
} catch (e: any) {
43-
// Catch "use cache" errors that bubble up from Next.js cache boundary
48+
if (isClerkUseCacheError(e)) {
49+
throw e;
50+
}
4451
if (isNextjsUseCacheError(e)) {
45-
throw new Error(`${USE_CACHE_ERROR_MESSAGE}\n\nOriginal error: ${e.message}`);
52+
throw new ClerkUseCacheError(`${USE_CACHE_ERROR_MESSAGE}\n\nOriginal error: ${e.message}`, e);
4653
}
4754
throw e;
4855
}

packages/nextjs/src/app-router/server/utils.ts

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
11
import { NextRequest } from 'next/server';
22

3-
// Pre-compiled regex patterns for error detection
4-
const USE_CACHE_PATTERN = /use cache|cache scope/i;
5-
const DYNAMIC_CACHE_PATTERN = /dynamic data source/i;
6-
// note: new error message syntax introduced in next@14.1.1-canary.21
7-
// but we still want to support older versions.
8-
// https://github.com/vercel/next.js/pull/61332 (dynamic-rendering.ts:153)
3+
const CLERK_USE_CACHE_MARKER = Symbol.for('clerk_use_cache_error');
4+
5+
/**
6+
* Custom error class for "use cache" errors with a symbol marker to prevent double-wrapping.
7+
*/
8+
export class ClerkUseCacheError extends Error {
9+
readonly [CLERK_USE_CACHE_MARKER] = true;
10+
11+
constructor(message: string, public readonly originalError?: Error) {
12+
super(message);
13+
this.name = 'ClerkUseCacheError';
14+
}
15+
}
16+
17+
export const isClerkUseCacheError = (e: unknown): e is ClerkUseCacheError => {
18+
return e instanceof Error && CLERK_USE_CACHE_MARKER in e;
19+
};
20+
21+
// Patterns for Next.js "use cache" errors - tightened to reduce false positives
22+
const USE_CACHE_WITH_DYNAMIC_API_PATTERN =
23+
/inside\s+"use cache"|"use cache".*(?:headers|cookies)|(?:headers|cookies).*"use cache"/i;
24+
const CACHE_SCOPE_PATTERN = /cache scope/i;
25+
const DYNAMIC_DATA_SOURCE_PATTERN = /dynamic data source/i;
26+
// https://github.com/vercel/next.js/pull/61332
927
const ROUTE_BAILOUT_PATTERN = /Route .*? needs to bail out of prerendering at this point because it used .*?./;
1028

1129
export const isPrerenderingBailout = (e: unknown) => {
@@ -14,24 +32,18 @@ export const isPrerenderingBailout = (e: unknown) => {
1432
}
1533

1634
const { message } = e;
17-
1835
const lowerCaseInput = message.toLowerCase();
19-
const dynamicServerUsage = lowerCaseInput.includes('dynamic server usage');
20-
const bailOutPrerendering = lowerCaseInput.includes('this page needs to bail out of prerendering');
21-
22-
// Next.js 16+ with cacheComponents: headers() rejects during prerendering
23-
// Error: "During prerendering, `headers()` rejects when the prerender is complete"
24-
const headersRejectsDuringPrerendering = lowerCaseInput.includes('during prerendering');
2536

2637
return (
27-
ROUTE_BAILOUT_PATTERN.test(message) || dynamicServerUsage || bailOutPrerendering || headersRejectsDuringPrerendering
38+
ROUTE_BAILOUT_PATTERN.test(message) ||
39+
lowerCaseInput.includes('dynamic server usage') ||
40+
lowerCaseInput.includes('this page needs to bail out of prerendering') ||
41+
lowerCaseInput.includes('during prerendering')
2842
);
2943
};
3044

3145
/**
32-
* Detects if the error is from using dynamic APIs inside a "use cache" component.
33-
* Next.js 16+ throws specific errors when headers(), cookies(), or other dynamic
34-
* APIs are accessed inside a cache scope.
46+
* Detects Next.js errors from using dynamic APIs (headers/cookies) inside "use cache".
3547
*/
3648
export const isNextjsUseCacheError = (e: unknown): boolean => {
3749
if (!(e instanceof Error)) {
@@ -40,19 +52,19 @@ export const isNextjsUseCacheError = (e: unknown): boolean => {
4052

4153
const { message } = e;
4254

43-
// Check for "use cache" or "cache scope" mentions
44-
if (USE_CACHE_PATTERN.test(message)) {
55+
// "use cache" with dynamic API context (e.g., 'used `headers()` inside "use cache"')
56+
if (USE_CACHE_WITH_DYNAMIC_API_PATTERN.test(message)) {
57+
return true;
58+
}
59+
60+
// "cache scope" with dynamic data source (e.g., 'Dynamic data sources inside a cache scope')
61+
if (CACHE_SCOPE_PATTERN.test(message) && DYNAMIC_DATA_SOURCE_PATTERN.test(message)) {
4562
return true;
4663
}
4764

48-
// Check compound pattern: requires both "dynamic data source" AND "cache"
49-
return DYNAMIC_CACHE_PATTERN.test(message) && message.toLowerCase().includes('cache');
65+
return false;
5066
};
5167

52-
/**
53-
* Error message for when auth()/currentUser() is called inside a "use cache" function.
54-
* Exported so it can be reused in auth.ts and currentUser.ts for consistent messaging.
55-
*/
5668
export const USE_CACHE_ERROR_MESSAGE =
5769
`Clerk: auth() and currentUser() cannot be called inside a "use cache" function. ` +
5870
`These functions access \`headers()\` internally, which is a dynamic API not allowed in cached contexts.\n\n` +
@@ -71,21 +83,18 @@ export const USE_CACHE_ERROR_MESSAGE =
7183

7284
export async function buildRequestLike(): Promise<NextRequest> {
7385
try {
74-
// Dynamically import next/headers, otherwise Next12 apps will break
75-
// @ts-expect-error: Cannot find module 'next/headers' or its corresponding type declarations.ts(2307)
86+
// @ts-expect-error - Dynamically import to avoid breaking Next 12 apps
7687
const { headers } = await import('next/headers');
7788
const resolvedHeaders = await headers();
7889
return new NextRequest('https://placeholder.com', { headers: resolvedHeaders });
7990
} catch (e: any) {
80-
// rethrow the error when react throws a prerendering bailout
8191
// https://nextjs.org/docs/messages/ppr-caught-error
8292
if (e && isPrerenderingBailout(e)) {
8393
throw e;
8494
}
8595

86-
// Provide a helpful error message for "use cache" components
8796
if (e && isNextjsUseCacheError(e)) {
88-
throw new Error(`${USE_CACHE_ERROR_MESSAGE}\n\nOriginal error: ${e.message}`);
97+
throw new ClerkUseCacheError(`${USE_CACHE_ERROR_MESSAGE}\n\nOriginal error: ${e.message}`, e);
8998
}
9099

91100
throw new Error(

packages/nextjs/src/server/clerkClient.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { constants } from '@clerk/backend/internal';
22

3-
import { buildRequestLike, isNextjsUseCacheError, isPrerenderingBailout } from '../app-router/server/utils';
3+
import { buildRequestLike, isClerkUseCacheError, isPrerenderingBailout } from '../app-router/server/utils';
44
import { createClerkClientWithOptions } from './createClerkClient';
55
import { getHeader } from './headers-utils';
66
import { clerkMiddlewareRequestDataStorage } from './middleware-storage';
@@ -21,8 +21,7 @@ const clerkClient = async () => {
2121
if (err && isPrerenderingBailout(err)) {
2222
throw err;
2323
}
24-
// Re-throw "use cache" errors with the helpful message from buildRequestLike
25-
if (err && isNextjsUseCacheError(err)) {
24+
if (err && isClerkUseCacheError(err)) {
2625
throw err;
2726
}
2827
}

0 commit comments

Comments
 (0)