Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/clever-maps-shop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/shared': patch
'@clerk/ui': patch
---

Add development-mode warning when users customize Clerk components using structural CSS patterns (combinators, positional pseudo-selectors, etc.) without pinning their `@clerk/ui` version. This helps users avoid breakages when internal DOM structure changes between versions.
7 changes: 7 additions & 0 deletions packages/shared/src/internal/clerk-js/warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ const warnings = {
'The <APIKeys/> component cannot be rendered when user API keys are disabled. Since user API keys are disabled, this is no-op.',
cannotRenderAPIKeysComponentForOrgWhenDisabled:
'The <APIKeys/> component cannot be rendered when organization API keys are disabled. Since organization API keys are disabled, this is no-op.',
advancedCustomizationWithoutVersionPinning:
'You are using appearance customization (elements or .cl- CSS selectors) that may rely on internal DOM structure. ' +
'This structure may change between versions, which could break your customizations.\n\n' +
'To ensure stability, install @clerk/ui and pass it to ClerkProvider:\n\n' +
" import { ui } from '@clerk/ui';\n\n" +
' <ClerkProvider ui={ui}>...</ClerkProvider>\n\n' +
'Learn more: https://clerk.com/docs/customization/versioning',
};

type SerializableWarnings = Serializable<typeof warnings>;
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/Components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import type { AvailableComponentProps } from './types';
import { buildVirtualRouterUrl } from './utils/buildVirtualRouterUrl';
import { disambiguateRedirectOptions } from './utils/disambiguateRedirectOptions';
import { extractCssLayerNameFromAppearance } from './utils/extractCssLayerNameFromAppearance';
import { warnAboutCustomizationWithoutPinning } from './utils/warnAboutCustomizationWithoutPinning';

// Re-export for ClerkUi
export { extractCssLayerNameFromAppearance };
Expand Down Expand Up @@ -236,7 +237,11 @@ export const mountComponentRenderer = (
getClerk={getClerk}
getEnvironment={getEnvironment}
options={options}
onComponentsMounted={deferredPromise.resolve}
onComponentsMounted={() => {
// Defer warning check to avoid blocking component mount
setTimeout(() => warnAboutCustomizationWithoutPinning(options.appearance), 0);
deferredPromise.resolve();
}}
moduleManager={moduleManager}
/>,
);
Expand Down
208 changes: 208 additions & 0 deletions packages/ui/src/utils/__tests__/detectClerkStylesheetUsage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

import { detectStructuralClerkCss } from '../detectClerkStylesheetUsage';

// Helper to create a mock CSSStyleRule
function createMockStyleRule(selectorText: string, cssText?: string): CSSStyleRule {
return {
type: CSSRule.STYLE_RULE,
selectorText,
cssText: cssText ?? `${selectorText} { }`,
} as CSSStyleRule;
}

// Helper to create a mock CSSStyleSheet
function createMockStyleSheet(rules: CSSRule[], href: string | null = null): CSSStyleSheet {
return {
href,
cssRules: rules as unknown as CSSRuleList,
} as CSSStyleSheet;
}

describe('detectStructuralClerkCss', () => {
let originalStyleSheets: StyleSheetList;

beforeEach(() => {
originalStyleSheets = document.styleSheets;
});

afterEach(() => {
Object.defineProperty(document, 'styleSheets', {
value: originalStyleSheets,
configurable: true,
});
});

function mockStyleSheets(sheets: CSSStyleSheet[]) {
Object.defineProperty(document, 'styleSheets', {
value: sheets,
configurable: true,
});
}

describe('should NOT flag simple .cl- class styling', () => {
test('single .cl- class with styles', () => {
mockStyleSheets([createMockStyleSheet([createMockStyleRule('.cl-button', '.cl-button { color: red; }')])]);

const hits = detectStructuralClerkCss();
expect(hits).toHaveLength(0);
});

test('.cl- class with pseudo-class like :hover', () => {
mockStyleSheets([
createMockStyleSheet([createMockStyleRule('.cl-button:hover', '.cl-button:hover { opacity: 0.8; }')]),
]);

const hits = detectStructuralClerkCss();
expect(hits).toHaveLength(0);
});

test('.cl- class with pseudo-element like ::before', () => {
mockStyleSheets([
createMockStyleSheet([createMockStyleRule('.cl-card::before', '.cl-card::before { content: ""; }')]),
]);

const hits = detectStructuralClerkCss();
expect(hits).toHaveLength(0);
});
});

describe('should flag structural patterns', () => {
test('.cl- class with descendant selector', () => {
mockStyleSheets([
createMockStyleSheet([createMockStyleRule('.cl-card .inner', '.cl-card .inner { padding: 10px; }')]),
]);

const hits = detectStructuralClerkCss();
expect(hits).toHaveLength(1);
expect(hits[0].selector).toBe('.cl-card .inner');
expect(hits[0].reason).toContain('descendant(combinator)');
});

test('descendant with .cl- class on right side', () => {
mockStyleSheets([
createMockStyleSheet([
createMockStyleRule('.my-wrapper .cl-button', '.my-wrapper .cl-button { color: blue; }'),
]),
]);

const hits = detectStructuralClerkCss();
expect(hits).toHaveLength(1);
expect(hits[0].reason).toContain('descendant(combinator)');
});

test('.cl- class with child combinator', () => {
mockStyleSheets([createMockStyleSheet([createMockStyleRule('.cl-card > div', '.cl-card > div { margin: 0; }')])]);

const hits = detectStructuralClerkCss();
expect(hits).toHaveLength(1);
expect(hits[0].reason).toContain('combinator(>+~)');
});

test('multiple .cl- classes in selector', () => {
mockStyleSheets([createMockStyleSheet([createMockStyleRule('.cl-card .cl-button', '.cl-card .cl-button { }')])]);

const hits = detectStructuralClerkCss();
expect(hits).toHaveLength(1);
expect(hits[0].reason).toContain('multiple-clerk-classes');
});

test('tag coupled with .cl- class', () => {
mockStyleSheets([createMockStyleSheet([createMockStyleRule('div.cl-button', 'div.cl-button { }')])]);

const hits = detectStructuralClerkCss();
expect(hits).toHaveLength(1);
expect(hits[0].reason).toContain('tag+cl-class');
});

test('positional pseudo-selector with .cl- class', () => {
mockStyleSheets([
createMockStyleSheet([createMockStyleRule('.cl-item:first-child', '.cl-item:first-child { }')]),
]);

const hits = detectStructuralClerkCss();
expect(hits).toHaveLength(1);
expect(hits[0].reason).toContain('positional-pseudo');
});

test(':nth-child with .cl- class', () => {
mockStyleSheets([
createMockStyleSheet([createMockStyleRule('.cl-item:nth-child(2)', '.cl-item:nth-child(2) { }')]),
]);

const hits = detectStructuralClerkCss();
expect(hits).toHaveLength(1);
expect(hits[0].reason).toContain('positional-pseudo');
});

test(':has() selector with .cl- class', () => {
mockStyleSheets([
createMockStyleSheet([createMockStyleRule('.cl-card:has(.active)', '.cl-card:has(.active) { }')]),
]);

const hits = detectStructuralClerkCss();
expect(hits).toHaveLength(1);
expect(hits[0].reason).toContain(':has()');
});

test('sibling combinator with .cl- class', () => {
mockStyleSheets([
createMockStyleSheet([createMockStyleRule('.cl-input + .cl-error', '.cl-input + .cl-error { }')]),
]);

const hits = detectStructuralClerkCss();
expect(hits).toHaveLength(1);
expect(hits[0].reason).toContain('combinator(>+~)');
});
});

describe('should handle multiple stylesheets', () => {
test('returns hits from all stylesheets', () => {
mockStyleSheets([
createMockStyleSheet([createMockStyleRule('.cl-card > div')]),
createMockStyleSheet([createMockStyleRule('.cl-button .icon')]),
]);

const hits = detectStructuralClerkCss();
expect(hits).toHaveLength(2);
});

test('includes stylesheet href in hits', () => {
mockStyleSheets([
createMockStyleSheet([createMockStyleRule('.cl-card > div')], 'https://example.com/styles.css'),
]);

const hits = detectStructuralClerkCss();
expect(hits[0].stylesheetHref).toBe('https://example.com/styles.css');
});
});

describe('should handle CORS-blocked stylesheets gracefully', () => {
test('skips stylesheets that throw on cssRules access', () => {
const blockedSheet = {
href: 'https://external.com/styles.css',
get cssRules() {
throw new DOMException('Blocked', 'SecurityError');
},
} as CSSStyleSheet;

mockStyleSheets([blockedSheet, createMockStyleSheet([createMockStyleRule('.cl-card > div')])]);

const hits = detectStructuralClerkCss();
expect(hits).toHaveLength(1);
});
});

describe('should handle comma-separated selectors', () => {
test('analyzes each selector in a list', () => {
mockStyleSheets([
createMockStyleSheet([createMockStyleRule('.cl-button, .cl-card > div', '.cl-button, .cl-card > div { }')]),
]);

const hits = detectStructuralClerkCss();
// Only ".cl-card > div" should be flagged, not ".cl-button"
expect(hits).toHaveLength(1);
expect(hits[0].selector).toBe('.cl-card > div');
});
});
});
Loading