diff --git a/.changeset/clever-maps-shop.md b/.changeset/clever-maps-shop.md
new file mode 100644
index 00000000000..c82431b8ae8
--- /dev/null
+++ b/.changeset/clever-maps-shop.md
@@ -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.
diff --git a/packages/shared/src/internal/clerk-js/warnings.ts b/packages/shared/src/internal/clerk-js/warnings.ts
index 80af67132ea..1233a9dd9d1 100644
--- a/packages/shared/src/internal/clerk-js/warnings.ts
+++ b/packages/shared/src/internal/clerk-js/warnings.ts
@@ -53,6 +53,13 @@ const warnings = {
'The component cannot be rendered when user API keys are disabled. Since user API keys are disabled, this is no-op.',
cannotRenderAPIKeysComponentForOrgWhenDisabled:
'The 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" +
+ ' ...\n\n' +
+ 'Learn more: https://clerk.com/docs/customization/versioning',
};
type SerializableWarnings = Serializable;
diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx
index 719187bfadd..c8c64a2f7cc 100644
--- a/packages/ui/src/Components.tsx
+++ b/packages/ui/src/Components.tsx
@@ -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 };
@@ -236,7 +237,14 @@ export const mountComponentRenderer = (
getClerk={getClerk}
getEnvironment={getEnvironment}
options={options}
- onComponentsMounted={deferredPromise.resolve}
+ onComponentsMounted={() => {
+ // Defer warning check to avoid blocking component mount
+ // Only check in development mode (based on publishable key, not NODE_ENV)
+ if (getClerk().instanceType === 'development') {
+ setTimeout(() => warnAboutCustomizationWithoutPinning(options), 0);
+ }
+ deferredPromise.resolve();
+ }}
moduleManager={moduleManager}
/>,
);
diff --git a/packages/ui/src/utils/__tests__/detectClerkStylesheetUsage.test.ts b/packages/ui/src/utils/__tests__/detectClerkStylesheetUsage.test.ts
new file mode 100644
index 00000000000..727abc0dbd7
--- /dev/null
+++ b/packages/ui/src/utils/__tests__/detectClerkStylesheetUsage.test.ts
@@ -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');
+ });
+ });
+});
diff --git a/packages/ui/src/utils/__tests__/warnAboutCustomizationWithoutPinning.test.ts b/packages/ui/src/utils/__tests__/warnAboutCustomizationWithoutPinning.test.ts
new file mode 100644
index 00000000000..4c8f4039873
--- /dev/null
+++ b/packages/ui/src/utils/__tests__/warnAboutCustomizationWithoutPinning.test.ts
@@ -0,0 +1,302 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+
+// Mock the dependencies before importing the module
+vi.mock('@clerk/shared/logger', () => ({
+ logger: {
+ warnOnce: vi.fn(),
+ },
+}));
+
+vi.mock('../detectClerkStylesheetUsage', () => ({
+ detectStructuralClerkCss: vi.fn(() => []),
+}));
+
+import { logger } from '@clerk/shared/logger';
+
+import { detectStructuralClerkCss } from '../detectClerkStylesheetUsage';
+import { warnAboutCustomizationWithoutPinning } from '../warnAboutCustomizationWithoutPinning';
+
+describe('warnAboutCustomizationWithoutPinning', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(detectStructuralClerkCss).mockReturnValue([]);
+ });
+
+ describe('version pinning check', () => {
+ test('does not warn when ui is provided (version is pinned)', () => {
+ warnAboutCustomizationWithoutPinning({
+ ui: { version: '1.0.0' } as any,
+ appearance: {
+ elements: { card: { '& > div': { color: 'red' } } },
+ },
+ });
+
+ expect(logger.warnOnce).not.toHaveBeenCalled();
+ });
+
+ test('warns when ui is not provided and structural customization is used', () => {
+ warnAboutCustomizationWithoutPinning({
+ appearance: {
+ elements: { card: { '& > div': { color: 'red' } } },
+ },
+ });
+
+ expect(logger.warnOnce).toHaveBeenCalledTimes(1);
+ });
+
+ test('still warns when clerkUiCtor is provided without ui (CDN scenario)', () => {
+ // clerkUiCtor is always set when loading from CDN, but ui is only set
+ // when the user explicitly imports @clerk/ui
+ warnAboutCustomizationWithoutPinning({
+ clerkUiCtor: class MockClerkUi {} as any,
+ appearance: {
+ elements: { card: { '& > div': { color: 'red' } } },
+ },
+ });
+
+ expect(logger.warnOnce).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('appearance.elements - should NOT warn', () => {
+ test('when elements is undefined', () => {
+ warnAboutCustomizationWithoutPinning({});
+
+ expect(logger.warnOnce).not.toHaveBeenCalled();
+ });
+
+ test('when elements is empty', () => {
+ warnAboutCustomizationWithoutPinning({ appearance: { elements: {} } });
+
+ expect(logger.warnOnce).not.toHaveBeenCalled();
+ });
+
+ test('for simple className string values', () => {
+ warnAboutCustomizationWithoutPinning({
+ appearance: {
+ elements: {
+ card: 'my-custom-card',
+ button: 'my-custom-button',
+ },
+ },
+ });
+
+ expect(logger.warnOnce).not.toHaveBeenCalled();
+ });
+
+ test('for simple CSS object without nested selectors', () => {
+ warnAboutCustomizationWithoutPinning({
+ appearance: {
+ elements: {
+ card: { backgroundColor: 'red', padding: '10px' },
+ button: { color: 'blue' },
+ },
+ },
+ });
+
+ expect(logger.warnOnce).not.toHaveBeenCalled();
+ });
+
+ test('for &:hover pseudo-class (non-structural)', () => {
+ warnAboutCustomizationWithoutPinning({
+ appearance: {
+ elements: {
+ button: {
+ '&:hover': { opacity: 0.8 },
+ '&:focus': { outline: '2px solid blue' },
+ },
+ },
+ },
+ });
+
+ expect(logger.warnOnce).not.toHaveBeenCalled();
+ });
+
+ test('for &::before pseudo-element (non-structural)', () => {
+ warnAboutCustomizationWithoutPinning({
+ appearance: {
+ elements: {
+ card: {
+ '&::before': { content: '""', position: 'absolute' },
+ },
+ },
+ },
+ });
+
+ expect(logger.warnOnce).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('appearance.elements - should warn', () => {
+ test('for nested selector with .cl- class reference', () => {
+ warnAboutCustomizationWithoutPinning({
+ appearance: {
+ elements: {
+ card: {
+ '& .cl-cardContent': { padding: '20px' },
+ },
+ },
+ },
+ });
+
+ expect(logger.warnOnce).toHaveBeenCalledTimes(1);
+ });
+
+ test('for child combinator with selector', () => {
+ warnAboutCustomizationWithoutPinning({
+ appearance: {
+ elements: {
+ card: {
+ '& > div': { margin: 0 },
+ },
+ },
+ },
+ });
+
+ expect(logger.warnOnce).toHaveBeenCalledTimes(1);
+ });
+
+ test('for descendant combinator with class', () => {
+ warnAboutCustomizationWithoutPinning({
+ appearance: {
+ elements: {
+ card: {
+ '& .inner': { padding: '10px' },
+ },
+ },
+ },
+ });
+
+ expect(logger.warnOnce).toHaveBeenCalledTimes(1);
+ });
+
+ test('for positional pseudo-selector', () => {
+ warnAboutCustomizationWithoutPinning({
+ appearance: {
+ elements: {
+ card: {
+ '&:first-child': { marginTop: 0 },
+ },
+ },
+ },
+ });
+
+ expect(logger.warnOnce).toHaveBeenCalledTimes(1);
+ });
+
+ test('for :nth-child selector', () => {
+ warnAboutCustomizationWithoutPinning({
+ appearance: {
+ elements: {
+ item: {
+ '&:nth-child(odd)': { backgroundColor: '#f5f5f5' },
+ },
+ },
+ },
+ });
+
+ expect(logger.warnOnce).toHaveBeenCalledTimes(1);
+ });
+
+ test('for :has() selector', () => {
+ warnAboutCustomizationWithoutPinning({
+ appearance: {
+ elements: {
+ card: {
+ '&:has(.active)': { borderColor: 'blue' },
+ },
+ },
+ },
+ });
+
+ expect(logger.warnOnce).toHaveBeenCalledTimes(1);
+ });
+
+ test('for deeply nested structural selectors', () => {
+ warnAboutCustomizationWithoutPinning({
+ appearance: {
+ elements: {
+ card: {
+ padding: '10px',
+ nested: {
+ '& > .child': { color: 'red' },
+ },
+ },
+ },
+ },
+ });
+
+ expect(logger.warnOnce).toHaveBeenCalledTimes(1);
+ });
+
+ test('for sibling combinator', () => {
+ warnAboutCustomizationWithoutPinning({
+ appearance: {
+ elements: {
+ input: {
+ '& + .error': { color: 'red' },
+ },
+ },
+ },
+ });
+
+ expect(logger.warnOnce).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('CSS stylesheet detection integration', () => {
+ test('warns when detectStructuralClerkCss returns hits', () => {
+ vi.mocked(detectStructuralClerkCss).mockReturnValue([
+ {
+ stylesheetHref: 'styles.css',
+ selector: '.cl-card > div',
+ cssText: '.cl-card > div { }',
+ reason: ['combinator(>+~)'],
+ },
+ ]);
+
+ warnAboutCustomizationWithoutPinning({});
+
+ expect(logger.warnOnce).toHaveBeenCalledTimes(1);
+ });
+
+ test('warns when both elements and CSS have structural patterns', () => {
+ vi.mocked(detectStructuralClerkCss).mockReturnValue([
+ {
+ stylesheetHref: null,
+ selector: '.cl-card .inner',
+ cssText: '.cl-card .inner { }',
+ reason: ['descendant(combinator)'],
+ },
+ ]);
+
+ warnAboutCustomizationWithoutPinning({
+ appearance: {
+ elements: {
+ button: { '& > span': { marginLeft: '8px' } },
+ },
+ },
+ });
+
+ // Should only warn once even with multiple structural patterns
+ expect(logger.warnOnce).toHaveBeenCalledTimes(1);
+ });
+
+ test('does not warn for structural CSS when ui is provided', () => {
+ vi.mocked(detectStructuralClerkCss).mockReturnValue([
+ {
+ stylesheetHref: 'styles.css',
+ selector: '.cl-card > div',
+ cssText: '.cl-card > div { }',
+ reason: ['combinator(>+~)'],
+ },
+ ]);
+
+ warnAboutCustomizationWithoutPinning({
+ ui: { version: '1.0.0' } as any,
+ });
+
+ expect(logger.warnOnce).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/ui/src/utils/cssPatterns.ts b/packages/ui/src/utils/cssPatterns.ts
new file mode 100644
index 00000000000..4f402bfafe4
--- /dev/null
+++ b/packages/ui/src/utils/cssPatterns.ts
@@ -0,0 +1,16 @@
+/**
+ * Shared CSS selector patterns for detecting structural DOM assumptions.
+ * Used by both stylesheet detection and CSS-in-JS analysis.
+ */
+
+// Matches .cl- class selectors (Clerk's internal class prefix)
+export const CLERK_CLASS_RE = /\.cl-[A-Za-z0-9_-]+/;
+
+// Matches attribute selectors targeting cl- classes (e.g., [class^="cl-"])
+export const CLERK_ATTR_RE = /\[\s*class\s*(\^=|\*=|\$=)\s*["']?[^"'\]]*cl-[^"'\]]*["']?\s*\]/i;
+
+// Positional pseudo-selectors that imply DOM shape assumptions
+export const POSITIONAL_PSEUDO_RE = /:(nth-child|nth-of-type|first-child|last-child|only-child|empty)\b/i;
+
+// :has() selector that implies DOM shape assumptions
+export const HAS_RE = /:has\(/i;
diff --git a/packages/ui/src/utils/detectClerkStylesheetUsage.ts b/packages/ui/src/utils/detectClerkStylesheetUsage.ts
new file mode 100644
index 00000000000..c193e66ea62
--- /dev/null
+++ b/packages/ui/src/utils/detectClerkStylesheetUsage.ts
@@ -0,0 +1,145 @@
+import { CLERK_ATTR_RE, CLERK_CLASS_RE, HAS_RE, POSITIONAL_PSEUDO_RE } from './cssPatterns';
+
+type ClerkStructuralHit = {
+ stylesheetHref: string | null;
+ selector: string;
+ cssText: string;
+ reason: string[];
+};
+
+function isProbablyClerkSelector(selector: string): boolean {
+ return CLERK_CLASS_RE.test(selector) || CLERK_ATTR_RE.test(selector);
+}
+
+// Split by commas safely-ish (won't perfectly handle :is(...) with commas, but good enough)
+function splitSelectorList(selectorText: string): string[] {
+ return selectorText
+ .split(',')
+ .map(s => s.trim())
+ .filter(Boolean);
+}
+
+/**
+ * Checks if a selector has a .cl- class combined with another selector
+ * (class, tag, attribute, or pseudo) via descendant or combinator relationship.
+ * Examples that match: ".cl-root .foo", ".foo .cl-root", ".cl-root > div", "div > .cl-button"
+ */
+function hasClerkWithAdjacency(selector: string): boolean {
+ // Pattern: .cl-class followed by combinator/space and another selector
+ // Or: another selector followed by combinator/space and .cl-class
+ const clerkWithDescendant = /\.cl-[A-Za-z0-9_-]+[\s>+~]+[.#\w\[:]/;
+ const descendantWithClerk = /[.#\w\]:][\s>+~]+\.cl-[A-Za-z0-9_-]+/;
+
+ return clerkWithDescendant.test(selector) || descendantWithClerk.test(selector);
+}
+
+function analyzeSelector(selector: string): { structural: boolean; reason: string[] } {
+ const reason: string[] = [];
+
+ // Only flag combinators/descendants if there's a .cl- class with adjacency
+ if (hasClerkWithAdjacency(selector)) {
+ if (/[>+~]/.test(selector)) {
+ reason.push('combinator(>+~)');
+ }
+ if (/\s+/.test(selector)) {
+ reason.push('descendant(combinator)');
+ }
+ }
+
+ if (POSITIONAL_PSEUDO_RE.test(selector)) {
+ reason.push('positional-pseudo');
+ }
+
+ if (HAS_RE.test(selector)) {
+ reason.push(':has()');
+ }
+
+ // Multiple clerk classes implies relationship like ".cl-a .cl-b"
+ const clCount = (selector.match(/\.cl-[A-Za-z0-9_-]+/g) || []).length;
+ if (clCount >= 2) {
+ reason.push('multiple-clerk-classes');
+ }
+
+ // Tag coupling: tag directly attached to .cl- class (e.g., "div.cl-button")
+ if (/(^|[\s>+~(])([a-z]+)\.cl-[A-Za-z0-9_-]+/i.test(selector)) {
+ reason.push('tag+cl-class');
+ }
+
+ // Structural if any of the brittle indicators show up
+ const structural =
+ reason.includes('combinator(>+~)') ||
+ reason.includes('descendant(combinator)') ||
+ reason.includes('positional-pseudo') ||
+ reason.includes(':has()') ||
+ reason.includes('multiple-clerk-classes') ||
+ reason.includes('tag+cl-class');
+
+ return { structural, reason };
+}
+
+function safeGetCssRules(sheet: CSSStyleSheet): CSSRuleList | null {
+ try {
+ return sheet.cssRules;
+ } catch {
+ return null;
+ }
+}
+
+// CSSRule.STYLE_RULE constant (value is 1) - using numeric literal for SSR/jsdom compatibility
+const CSS_STYLE_RULE = 1;
+
+function walkRules(rules: CSSRuleList, sheet: CSSStyleSheet, out: ClerkStructuralHit[]) {
+ for (const rule of Array.from(rules)) {
+ // Check for CSSStyleRule (type 1) using duck typing for jsdom compatibility
+ if (rule.type === CSS_STYLE_RULE && 'selectorText' in rule) {
+ const styleRule = rule as CSSStyleRule;
+ const selectorText = styleRule.selectorText || '';
+ for (const selector of splitSelectorList(selectorText)) {
+ if (!isProbablyClerkSelector(selector)) {
+ continue;
+ }
+
+ const { structural, reason } = analyzeSelector(selector);
+ if (!structural) {
+ continue;
+ }
+
+ out.push({
+ stylesheetHref: sheet.href ?? null,
+ selector,
+ cssText: styleRule.cssText,
+ reason,
+ });
+ }
+ continue;
+ }
+
+ // Handle nested rules (@media, @supports, etc.) using duck typing
+ if ('cssRules' in rule && rule.cssRules) {
+ walkRules(rule.cssRules as CSSRuleList, sheet, out);
+ }
+ }
+}
+
+/**
+ * Detects CSS rules that target Clerk's .cl- classes in a structural way
+ * (using combinators, positional pseudo-selectors, :has(), etc.)
+ *
+ * Simple class targeting like `.cl-button { color: red; }` is NOT flagged.
+ * Structural targeting like `.cl-card > .cl-button` or `div.cl-button` IS flagged.
+ */
+export function detectStructuralClerkCss(): ClerkStructuralHit[] {
+ if (typeof document === 'undefined') {
+ return [];
+ }
+
+ const hits: ClerkStructuralHit[] = [];
+ for (const sheet of Array.from(document.styleSheets)) {
+ const rules = safeGetCssRules(sheet);
+ if (!rules) {
+ continue; // cross-origin / blocked
+ }
+ walkRules(rules, sheet, hits);
+ }
+ return hits;
+}
diff --git a/packages/ui/src/utils/warnAboutCustomizationWithoutPinning.ts b/packages/ui/src/utils/warnAboutCustomizationWithoutPinning.ts
new file mode 100644
index 00000000000..3979914bdca
--- /dev/null
+++ b/packages/ui/src/utils/warnAboutCustomizationWithoutPinning.ts
@@ -0,0 +1,140 @@
+import { warnings } from '@clerk/shared/internal/clerk-js/warnings';
+import { logger } from '@clerk/shared/logger';
+import type { ClerkOptions } from '@clerk/shared/types';
+
+import { CLERK_CLASS_RE, HAS_RE, POSITIONAL_PSEUDO_RE } from './cssPatterns';
+import { detectStructuralClerkCss } from './detectClerkStylesheetUsage';
+
+/**
+ * Checks if a CSS-in-JS selector has adjacency with another selector.
+ * For nested selectors like "& > .foo" or "& .cl-something", we check if
+ * there's a .cl- class combined with another selector via combinator/descendant.
+ */
+function hasAdjacencyWithOtherSelector(selector: string): boolean {
+ // Remove the leading "&" to analyze the rest
+ const rest = selector.replace(/^&\s*/, '');
+
+ // Check if there's a .cl- class in the selector
+ const hasClerkClass = CLERK_CLASS_RE.test(rest);
+
+ // Check if there's another selector (class, tag, id, attribute)
+ const hasOtherSelector = /[.#\w\[:]/.test(rest.replace(/\.cl-[A-Za-z0-9_-]+/g, ''));
+
+ // Only structural if both a .cl- class and another selector exist
+ // OR if it references a .cl- class (assumes internal structure)
+ return hasClerkClass || (hasOtherSelector && /[>+~\s]/.test(selector));
+}
+
+/**
+ * Checks if a CSS-in-JS selector key indicates structural DOM assumptions.
+ */
+function isStructuralSelector(selector: string): boolean {
+ // Only analyze nested selectors (those starting with &)
+ if (!selector.startsWith('&')) {
+ return false;
+ }
+
+ // Check for references to .cl- classes (assumes internal structure)
+ if (CLERK_CLASS_RE.test(selector)) {
+ return true;
+ }
+
+ // Check for positional pseudo-selectors
+ if (POSITIONAL_PSEUDO_RE.test(selector)) {
+ return true;
+ }
+
+ // Check for :has()
+ if (HAS_RE.test(selector)) {
+ return true;
+ }
+
+ // Check for combinators/descendants only if there's adjacency with another selector
+ if (hasAdjacencyWithOtherSelector(selector)) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Recursively checks if a CSS-in-JS value contains structural selectors.
+ */
+function hasStructuralSelectors(value: unknown): boolean {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+
+ for (const [key, nestedValue] of Object.entries(value)) {
+ // Check if this key is a structural selector
+ if (isStructuralSelector(key)) {
+ return true;
+ }
+
+ // Recursively check nested objects
+ if (hasStructuralSelectors(nestedValue)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Detects if appearance.elements contains structural CSS patterns.
+ */
+function hasStructuralElementsUsage(elements: Record): boolean {
+ for (const value of Object.values(elements)) {
+ // String values (classNames) are safe - no structural assumptions
+ if (typeof value === 'string') {
+ continue;
+ }
+
+ // Check CSS objects for structural selectors
+ if (hasStructuralSelectors(value)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Warns users when they are using customization
+ * (structural appearance.elements or structural CSS targeting .cl- classes)
+ * without pinning their @clerk/ui version.
+ *
+ * Note: The caller should check clerk.instanceType === 'development' before calling.
+ * This function assumes it's only called in development mode.
+ *
+ * If the user has explicitly imported @clerk/ui and passed it via the `ui` option,
+ * they have "pinned" their version and no warning is shown.
+ *
+ * Note: We check `options.ui` (not `options.clerkUiCtor`) because clerkUiCtor is
+ * always set when loading from CDN via window.__internal_ClerkUiCtor.
+ */
+export function warnAboutCustomizationWithoutPinning(options?: ClerkOptions): void {
+ // If ui is explicitly provided, the user has pinned their version
+ if (options?.ui) {
+ return;
+ }
+
+ const appearance = options?.appearance;
+ const hasStructuralElements =
+ appearance?.elements &&
+ Object.keys(appearance.elements).length > 0 &&
+ hasStructuralElementsUsage(appearance.elements as Record);
+
+ // Early return if we already found structural usage - no need to scan stylesheets
+ if (hasStructuralElements) {
+ logger.warnOnce(warnings.advancedCustomizationWithoutVersionPinning);
+ return;
+ }
+
+ // Only scan stylesheets if appearance.elements didn't trigger warning
+ const structuralCssHits = detectStructuralClerkCss();
+
+ if (structuralCssHits.length > 0) {
+ logger.warnOnce(warnings.advancedCustomizationWithoutVersionPinning);
+ }
+}
diff --git a/packages/ui/vitest.config.mts b/packages/ui/vitest.config.mts
new file mode 100644
index 00000000000..1e424f0bed3
--- /dev/null
+++ b/packages/ui/vitest.config.mts
@@ -0,0 +1,14 @@
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [react({ jsxRuntime: 'automatic', jsxImportSource: '@emotion/react' })],
+ define: {
+ __PKG_VERSION__: JSON.stringify('test'),
+ },
+ test: {
+ environment: 'jsdom',
+ include: ['**/*.{test,spec}.{ts,tsx}'],
+ globals: false,
+ },
+});