Skip to content
Merged
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.
1 change: 1 addition & 0 deletions packages/shared/src/internal/clerk-js/warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const createMessageForDisabledBilling = (componentName: 'PricingTable' | 'Checko
`The <${componentName}/> component cannot be rendered when billing is disabled. Visit 'https://dashboard.clerk.com/last-active?path=billing/settings' to follow the necessary steps to enable billing. Since billing is disabled, this is no-op.`,
);
};

const warnings = {
cannotRenderComponentWhenSessionExists:
'The <SignUp/> and <SignIn/> components cannot render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the Home URL instead.',
Expand Down
14 changes: 13 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,18 @@ 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') {
const scheduleWarningCheck =
typeof requestIdleCallback === 'function'
? requestIdleCallback
: (cb: () => void) => setTimeout(cb, 0);
scheduleWarningCheck(() => warnAboutCustomizationWithoutPinning(options));
}
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
Loading