Skip to content
Open
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
2 changes: 2 additions & 0 deletions .changeset/warm-cups-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
4 changes: 2 additions & 2 deletions packages/ui/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -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" },
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/customizables/AppearanceContext.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 <AppearanceContext.Provider value={ctxValue}>{props.children}</AppearanceContext.Provider>;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<ClerkInstanceContext.Provider value={clerkValue}>
<OptionsContext.Provider value={optionsValue as any}>{children}</OptionsContext.Provider>
</ClerkInstanceContext.Provider>
);
};
}

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();
});
});
});
57 changes: 57 additions & 0 deletions packages/ui/src/hooks/useWarnAboutCustomizationWithoutPinning.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"');
});

Expand Down Expand Up @@ -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"');
});

Expand Down Expand Up @@ -315,25 +318,26 @@ 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({});

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', () => {
Expand All @@ -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();
});
});
64 changes: 51 additions & 13 deletions packages/ui/src/utils/warnAboutCustomizationWithoutPinning.ts
Original file line number Diff line number Diff line change
@@ -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` +
` <ClerkProvider ui={ui} />\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'`,
` <ClerkProvider ui={ui}>`,
``,
`Learn more: https://clerk.com/docs/reference/components/versioning`,
`(code=structural_css_pin_clerk_ui)`,
].join('\n');
}

/**
Expand Down Expand Up @@ -118,6 +125,37 @@ function collectElementPatterns(elements: Record<string, unknown>): 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<string, unknown>);

if (patterns.length > 0) {
logger.warnOnce(formatStructuralCssWarning(patterns));
}
}

/**
* Warns users when they are using customization
* (structural appearance.elements or structural CSS targeting .cl- classes)
Expand Down