diff --git a/.changeset/warm-cups-warn.md b/.changeset/warm-cups-warn.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/warm-cups-warn.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json
index fc46d205e39..d8c77d57f20 100644
--- a/packages/ui/bundlewatch.config.json
+++ b/packages/ui/bundlewatch.config.json
@@ -1,7 +1,7 @@
{
"files": [
- { "path": "./dist/ui.browser.js", "maxSize": "34KB" },
- { "path": "./dist/ui.legacy.browser.js", "maxSize": "72KB" },
+ { "path": "./dist/ui.browser.js", "maxSize": "34.5KB" },
+ { "path": "./dist/ui.legacy.browser.js", "maxSize": "73KB" },
{ "path": "./dist/framework*.js", "maxSize": "44KB" },
{ "path": "./dist/vendors*.js", "maxSize": "73KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "130KB" },
diff --git a/packages/ui/src/customizables/AppearanceContext.tsx b/packages/ui/src/customizables/AppearanceContext.tsx
index 93f3fb37be5..8bfee6007b9 100644
--- a/packages/ui/src/customizables/AppearanceContext.tsx
+++ b/packages/ui/src/customizables/AppearanceContext.tsx
@@ -1,6 +1,7 @@
import { createContextAndHook, useDeepEqualMemo } from '@clerk/shared/react';
import React from 'react';
+import { useWarnAboutCustomizationWithoutPinning } from '../hooks/useWarnAboutCustomizationWithoutPinning';
import type { AppearanceCascade, ParsedAppearance } from './parseAppearance';
import { parseAppearance } from './parseAppearance';
@@ -16,6 +17,9 @@ const AppearanceProvider = (props: AppearanceProviderProps) => {
return { value };
}, [props.appearance, props.globalAppearance, props.appearanceKey]);
+ // Check component-level appearance for structural CSS patterns
+ useWarnAboutCustomizationWithoutPinning(props.appearance);
+
return {props.children};
};
diff --git a/packages/ui/src/hooks/__tests__/useWarnAboutCustomizationWithoutPinning.test.tsx b/packages/ui/src/hooks/__tests__/useWarnAboutCustomizationWithoutPinning.test.tsx
new file mode 100644
index 00000000000..a29822ac445
--- /dev/null
+++ b/packages/ui/src/hooks/__tests__/useWarnAboutCustomizationWithoutPinning.test.tsx
@@ -0,0 +1,113 @@
+import { ClerkInstanceContext } from '@clerk/shared/react';
+import { renderHook } from '@testing-library/react';
+import React from 'react';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { OptionsContext } from '../../contexts/OptionsContext';
+import { useWarnAboutCustomizationWithoutPinning } from '../useWarnAboutCustomizationWithoutPinning';
+
+// Mock the warning function
+vi.mock('../../utils/warnAboutCustomizationWithoutPinning', () => ({
+ warnAboutComponentAppearance: vi.fn(),
+}));
+
+import { warnAboutComponentAppearance } from '../../utils/warnAboutCustomizationWithoutPinning';
+
+const mockWarnAboutComponentAppearance = vi.mocked(warnAboutComponentAppearance);
+
+// Helper to create a wrapper with contexts
+function createWrapper({
+ clerkInstanceType = 'development',
+ hasClerkContext = true,
+ uiPinned = false,
+}: {
+ clerkInstanceType?: 'development' | 'production';
+ hasClerkContext?: boolean;
+ uiPinned?: boolean;
+} = {}) {
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ const clerkValue = hasClerkContext
+ ? {
+ value: {
+ instanceType: clerkInstanceType,
+ } as any,
+ }
+ : undefined;
+
+ const optionsValue = uiPinned ? { ui: { version: '1.0.0' } } : {};
+
+ return (
+
+ {children}
+
+ );
+ };
+}
+
+describe('useWarnAboutCustomizationWithoutPinning', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Mock requestIdleCallback since it may not be available in test environment
+ vi.stubGlobal('requestIdleCallback', (cb: () => void) => {
+ cb();
+ return 1;
+ });
+ vi.stubGlobal('cancelIdleCallback', vi.fn());
+ });
+
+ describe('in development mode', () => {
+ test('calls warnAboutComponentAppearance when component mounts with appearance', () => {
+ const appearance = {
+ elements: { card: { '& > div': { color: 'red' } } },
+ };
+
+ renderHook(() => useWarnAboutCustomizationWithoutPinning(appearance), {
+ wrapper: createWrapper({ clerkInstanceType: 'development' }),
+ });
+
+ expect(mockWarnAboutComponentAppearance).toHaveBeenCalledTimes(1);
+ expect(mockWarnAboutComponentAppearance).toHaveBeenCalledWith(appearance, false);
+ });
+
+ test('passes uiPinned=true when options.ui is set', () => {
+ const appearance = {
+ elements: { card: { '& > div': { color: 'red' } } },
+ };
+
+ renderHook(() => useWarnAboutCustomizationWithoutPinning(appearance), {
+ wrapper: createWrapper({ clerkInstanceType: 'development', uiPinned: true }),
+ });
+
+ expect(mockWarnAboutComponentAppearance).toHaveBeenCalledTimes(1);
+ expect(mockWarnAboutComponentAppearance).toHaveBeenCalledWith(appearance, true);
+ });
+ });
+
+ describe('in production mode', () => {
+ test('does not call warnAboutComponentAppearance', () => {
+ const appearance = {
+ elements: { card: { '& > div': { color: 'red' } } },
+ };
+
+ renderHook(() => useWarnAboutCustomizationWithoutPinning(appearance), {
+ wrapper: createWrapper({ clerkInstanceType: 'production' }),
+ });
+
+ expect(mockWarnAboutComponentAppearance).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('without ClerkProvider context', () => {
+ test('does not call warnAboutComponentAppearance (graceful degradation for tests)', () => {
+ const appearance = {
+ elements: { card: { '& > div': { color: 'red' } } },
+ };
+
+ renderHook(() => useWarnAboutCustomizationWithoutPinning(appearance), {
+ wrapper: createWrapper({ hasClerkContext: false }),
+ });
+
+ expect(mockWarnAboutComponentAppearance).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/ui/src/hooks/useWarnAboutCustomizationWithoutPinning.ts b/packages/ui/src/hooks/useWarnAboutCustomizationWithoutPinning.ts
new file mode 100644
index 00000000000..9283863efa3
--- /dev/null
+++ b/packages/ui/src/hooks/useWarnAboutCustomizationWithoutPinning.ts
@@ -0,0 +1,57 @@
+import { ClerkInstanceContext, OptionsContext as SharedOptionsContext } from '@clerk/shared/react';
+import { useContext, useEffect } from 'react';
+
+import { OptionsContext } from '../contexts/OptionsContext';
+import type { Appearance } from '../internal/appearance';
+import { warnAboutComponentAppearance } from '../utils/warnAboutCustomizationWithoutPinning';
+
+/**
+ * Hook that checks component-level appearance for structural CSS patterns
+ * and warns if found (when version is not pinned).
+ *
+ * This is called when individual components mount with their own appearance,
+ * to catch structural CSS that wasn't passed through ClerkProvider.
+ *
+ * Only runs in development mode.
+ *
+ * Note: This hook is safe to use outside of ClerkProvider context (e.g., in tests)
+ * - it will simply not perform any checks in that case.
+ */
+export function useWarnAboutCustomizationWithoutPinning(appearance: Appearance | undefined): void {
+ // Access contexts directly to handle cases where they might not be available (e.g., in tests)
+ const clerkCtx = useContext(ClerkInstanceContext);
+ // Try our local OptionsContext first, then fall back to shared (if any)
+ const localOptions = useContext(OptionsContext);
+ const sharedOptions = useContext(SharedOptionsContext);
+ const options = localOptions ?? sharedOptions;
+
+ // Cast to any to access `ui` property which exists on IsomorphicClerkOptions but not ClerkOptions
+ // This matches the pattern used in warnAboutCustomizationWithoutPinning
+ const uiPinned = !!(options as any)?.ui;
+
+ useEffect(() => {
+ // Skip if clerk context is not available (e.g., in tests)
+ if (!clerkCtx?.value) {
+ return;
+ }
+
+ // Only check in development mode
+ if (clerkCtx.value.instanceType !== 'development') {
+ return;
+ }
+
+ // Defer warning check to avoid blocking component mount
+ const scheduleWarningCheck =
+ typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb: () => void) => setTimeout(cb, 0);
+
+ const handle = scheduleWarningCheck(() => {
+ warnAboutComponentAppearance(appearance, uiPinned);
+ });
+
+ return () => {
+ if (typeof cancelIdleCallback === 'function' && typeof handle === 'number') {
+ cancelIdleCallback(handle);
+ }
+ };
+ }, [clerkCtx?.value, appearance, uiPinned]);
+}
diff --git a/packages/ui/src/utils/__tests__/warnAboutCustomizationWithoutPinning.test.ts b/packages/ui/src/utils/__tests__/warnAboutCustomizationWithoutPinning.test.ts
index 63f01603f13..9adef62ca15 100644
--- a/packages/ui/src/utils/__tests__/warnAboutCustomizationWithoutPinning.test.ts
+++ b/packages/ui/src/utils/__tests__/warnAboutCustomizationWithoutPinning.test.ts
@@ -14,7 +14,10 @@ vi.mock('../detectClerkStylesheetUsage', () => ({
import { logger } from '@clerk/shared/logger';
import { detectStructuralClerkCss } from '../detectClerkStylesheetUsage';
-import { warnAboutCustomizationWithoutPinning } from '../warnAboutCustomizationWithoutPinning';
+import {
+ warnAboutComponentAppearance,
+ warnAboutCustomizationWithoutPinning,
+} from '../warnAboutCustomizationWithoutPinning';
const getWarningMessage = () => {
const calls = vi.mocked(logger.warnOnce).mock.calls;
@@ -48,7 +51,7 @@ describe('warnAboutCustomizationWithoutPinning', () => {
expect(logger.warnOnce).toHaveBeenCalledTimes(1);
const message = getWarningMessage();
- expect(message).toContain('[CLERK_W001]');
+ expect(message).toContain('(code=structural_css_pin_clerk_ui)');
expect(message).toContain('elements.card "& > div"');
});
@@ -269,7 +272,7 @@ describe('warnAboutCustomizationWithoutPinning', () => {
expect(logger.warnOnce).toHaveBeenCalledTimes(1);
const message = getWarningMessage();
- expect(message).toContain('[CLERK_W001]');
+ expect(message).toContain('(code=structural_css_pin_clerk_ui)');
expect(message).toContain('CSS ".cl-card > div"');
});
@@ -315,13 +318,15 @@ describe('warnAboutCustomizationWithoutPinning', () => {
expect(logger.warnOnce).not.toHaveBeenCalled();
});
- test('truncates pattern list when more than 3 patterns are found', () => {
+ test('truncates pattern list when more than 5 patterns are found', () => {
vi.mocked(detectStructuralClerkCss).mockReturnValue([
{ stylesheetHref: null, selector: '.cl-a > div', cssText: '', reason: [] },
{ stylesheetHref: null, selector: '.cl-b > div', cssText: '', reason: [] },
{ stylesheetHref: null, selector: '.cl-c > div', cssText: '', reason: [] },
{ stylesheetHref: null, selector: '.cl-d > div', cssText: '', reason: [] },
{ stylesheetHref: null, selector: '.cl-e > div', cssText: '', reason: [] },
+ { stylesheetHref: null, selector: '.cl-f > div', cssText: '', reason: [] },
+ { stylesheetHref: null, selector: '.cl-g > div', cssText: '', reason: [] },
]);
warnAboutCustomizationWithoutPinning({});
@@ -329,11 +334,10 @@ describe('warnAboutCustomizationWithoutPinning', () => {
expect(logger.warnOnce).toHaveBeenCalledTimes(1);
const message = getWarningMessage();
expect(message).toContain('(+2 more)');
- // Should only show first 3 patterns
- expect(message).toContain('CSS ".cl-a > div"');
- expect(message).toContain('CSS ".cl-b > div"');
- expect(message).toContain('CSS ".cl-c > div"');
- expect(message).not.toContain('.cl-d > div');
+ // Should show first 5 patterns as bullet points
+ expect(message).toContain('- CSS ".cl-a > div"');
+ expect(message).toContain('- CSS ".cl-e > div"');
+ expect(message).not.toContain('.cl-f > div');
});
test('warning message includes documentation link', () => {
@@ -344,7 +348,49 @@ describe('warnAboutCustomizationWithoutPinning', () => {
});
const message = getWarningMessage();
- expect(message).toContain('https://clerk.com/docs/customization/versioning');
+ expect(message).toContain('https://clerk.com/docs/reference/components/versioning');
});
});
});
+
+describe('warnAboutComponentAppearance', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('does not warn when uiPinned is true', () => {
+ warnAboutComponentAppearance(
+ {
+ elements: { card: { '& > div': { color: 'red' } } },
+ },
+ true,
+ );
+
+ expect(logger.warnOnce).not.toHaveBeenCalled();
+ });
+
+ test('warns when uiPinned is false and structural customization is used', () => {
+ warnAboutComponentAppearance(
+ {
+ elements: { card: { '& > div': { color: 'red' } } },
+ },
+ false,
+ );
+
+ expect(logger.warnOnce).toHaveBeenCalledTimes(1);
+ const message = getWarningMessage();
+ expect(message).toContain('(code=structural_css_pin_clerk_ui)');
+ expect(message).toContain('elements.card "& > div"');
+ });
+
+ test('does not call detectStructuralClerkCss (only checks elements, not stylesheets)', () => {
+ warnAboutComponentAppearance(
+ {
+ elements: { card: { '& > div': { color: 'red' } } },
+ },
+ false,
+ );
+
+ expect(detectStructuralClerkCss).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/ui/src/utils/warnAboutCustomizationWithoutPinning.ts b/packages/ui/src/utils/warnAboutCustomizationWithoutPinning.ts
index 8ed7c9cf9b7..2c240fa36a5 100644
--- a/packages/ui/src/utils/warnAboutCustomizationWithoutPinning.ts
+++ b/packages/ui/src/utils/warnAboutCustomizationWithoutPinning.ts
@@ -1,23 +1,30 @@
import { logger } from '@clerk/shared/logger';
import type { ClerkOptions } from '@clerk/shared/types';
+import type { Appearance } from '../internal/appearance';
import { CLERK_CLASS_RE, HAS_RE, POSITIONAL_PSEUDO_RE } from './cssPatterns';
import { detectStructuralClerkCss } from './detectClerkStylesheetUsage';
function formatStructuralCssWarning(patterns: string[]): string {
- const patternsDisplay = patterns.length > 0 ? patterns.slice(0, 3).join(', ') : 'structural CSS';
- const truncated = patterns.length > 3 ? ` (+${patterns.length - 3} more)` : '';
-
- return (
- `🔒 Clerk:\n` +
- `[CLERK_W001] Structural CSS detected\n\n` +
- `Found: ${patternsDisplay}${truncated}\n\n` +
- `May break on updates. Pin your version:\n` +
- ` npm install @clerk/ui && import { ui } from '@clerk/ui'\n` +
- ` \n\n` +
- `https://clerk.com/docs/customization/versioning\n` +
- `(This notice only appears in development)`
- );
+ const displayPatterns = patterns.slice(0, 5);
+ const patternsList = displayPatterns.map(p => ` - ${p}`).join('\n');
+ const truncated = patterns.length > 5 ? `\n (+${patterns.length - 5} more)` : '';
+
+ return [
+ `Clerk: Structural CSS detected that may break on updates.`,
+ ``,
+ `Found:`,
+ patternsList + truncated,
+ ``,
+ `These selectors depend on the internal DOM structure of Clerk's components, which may change when Clerk deploys component updates.`,
+ `To prevent breaking changes, install @clerk/ui and pass it to ClerkProvider:`,
+ ``,
+ ` import { ui } from '@clerk/ui'`,
+ ` `,
+ ``,
+ `Learn more: https://clerk.com/docs/reference/components/versioning`,
+ `(code=structural_css_pin_clerk_ui)`,
+ ].join('\n');
}
/**
@@ -118,6 +125,37 @@ function collectElementPatterns(elements: Record): string[] {
return patterns;
}
+/**
+ * Checks component-level appearance.elements for structural CSS patterns
+ * and warns if found (when version is not pinned).
+ *
+ * This is called when individual components mount with their own appearance,
+ * to catch structural CSS that wasn't passed through ClerkProvider.
+ *
+ * Note: The caller should check clerk.instanceType === 'development' before calling.
+ * This function assumes it's only called in development mode.
+ *
+ * @param appearance - The component-level appearance to check
+ * @param uiPinned - Whether the user has pinned their @clerk/ui version via options.ui
+ */
+export function warnAboutComponentAppearance(appearance: Appearance | undefined, uiPinned: boolean): void {
+ // If ui is explicitly provided, the user has pinned their version
+ if (uiPinned) {
+ return;
+ }
+
+ // No appearance to check
+ if (!appearance?.elements || Object.keys(appearance.elements).length === 0) {
+ return;
+ }
+
+ const patterns = collectElementPatterns(appearance.elements as Record);
+
+ if (patterns.length > 0) {
+ logger.warnOnce(formatStructuralCssWarning(patterns));
+ }
+}
+
/**
* Warns users when they are using customization
* (structural appearance.elements or structural CSS targeting .cl- classes)