Skip to content
Merged
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: 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();
});
});
});
61 changes: 61 additions & 0 deletions packages/ui/src/hooks/useWarnAboutCustomizationWithoutPinning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { ClerkInstanceContext, OptionsContext as SharedOptionsContext, useDeepEqualMemo } 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;

// Stabilize the appearance reference to prevent effect re-runs when consumers
// pass inline objects (e.g., <SignIn appearance={{ elements: { card: {} } }} />)
const stableAppearance = useDeepEqualMemo(() => appearance, [appearance]);

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 useIdleCallback = typeof requestIdleCallback === 'function';
const handle = useIdleCallback
? requestIdleCallback(() => warnAboutComponentAppearance(stableAppearance, uiPinned))
: setTimeout(() => warnAboutComponentAppearance(stableAppearance, uiPinned), 0);

return () => {
if (useIdleCallback) {
cancelIdleCallback(handle as number);
} else {
clearTimeout(handle as ReturnType<typeof setTimeout>);
}
};
}, [clerkCtx?.value, stableAppearance, 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();
});
});
Loading
Loading