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, + }, +});