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)