diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 8d1f5450c43..5d5487377f0 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -112,6 +112,7 @@ import { isRedirectForFAPIInitiatedFlow, noOrganizationExists, noUserExists, + processCssLayerNameExtraction, removeClerkQueryParam, requiresUserInput, sessionExistsAndSingleSessionModeEnabled, @@ -2727,9 +2728,18 @@ export class Clerk implements ClerkInterface { }; #initOptions = (options?: ClerkOptions): ClerkOptions => { + const processedOptions = options ? { ...options } : {}; + + // Extract cssLayerName from baseTheme if present and move it to appearance level + if (processedOptions.appearance) { + processedOptions.appearance = processCssLayerNameExtraction(processedOptions.appearance); + } + + console.log('processedOptions', processedOptions); + return { ...defaultOptions, - ...options, + ...processedOptions, allowedRedirectOrigins: createAllowedRedirectOrigins( options?.allowedRedirectOrigins, this.frontendApi, diff --git a/packages/clerk-js/src/utils/__tests__/appearance.spec.ts b/packages/clerk-js/src/utils/__tests__/appearance.spec.ts new file mode 100644 index 00000000000..3939c04be48 --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/appearance.spec.ts @@ -0,0 +1,173 @@ +import type { Appearance, BaseTheme } from '@clerk/types'; +import { describe, expect, it } from 'vitest'; + +import { processCssLayerNameExtraction } from '../appearance'; + +describe('processCssLayerNameExtraction', () => { + it('extracts cssLayerName from single baseTheme and moves it to appearance level', () => { + const appearance: Appearance = { + baseTheme: { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'theme-layer', + }, + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBe('theme-layer'); + expect(result?.baseTheme).toBeDefined(); + if (result?.baseTheme && !Array.isArray(result.baseTheme)) { + expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + expect(result.baseTheme.__type).toBe('prebuilt_appearance'); + } + }); + + it('preserves appearance-level cssLayerName over baseTheme cssLayerName', () => { + const appearance: Appearance = { + cssLayerName: 'appearance-layer', + baseTheme: { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'theme-layer', + }, + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBe('appearance-layer'); + if (result?.baseTheme && !Array.isArray(result.baseTheme)) { + expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + } + }); + + it('extracts cssLayerName from first theme in array that has one', () => { + const appearance: Appearance = { + baseTheme: [ + { + __type: 'prebuilt_appearance' as const, + }, + { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'first-layer', + }, + { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'second-layer', + }, + ], + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBe('first-layer'); + expect(result?.baseTheme).toBeDefined(); + if (result?.baseTheme && Array.isArray(result.baseTheme)) { + expect(result.baseTheme).toHaveLength(3); + expect((result.baseTheme[0] as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + expect((result.baseTheme[1] as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + expect((result.baseTheme[2] as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + result.baseTheme.forEach(theme => { + expect(theme.__type).toBe('prebuilt_appearance'); + }); + } + }); + + it('preserves appearance-level cssLayerName over array baseTheme cssLayerName', () => { + const appearance: Appearance = { + cssLayerName: 'appearance-layer', + baseTheme: [ + { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'theme1-layer', + }, + { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'theme2-layer', + }, + ], + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBe('appearance-layer'); + if (result?.baseTheme && Array.isArray(result.baseTheme)) { + result.baseTheme.forEach(theme => { + expect((theme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + }); + } + }); + + it('handles single baseTheme without cssLayerName', () => { + const appearance: Appearance = { + baseTheme: { + __type: 'prebuilt_appearance' as const, + }, + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBeUndefined(); + if (result?.baseTheme && !Array.isArray(result.baseTheme)) { + expect(result.baseTheme.__type).toBe('prebuilt_appearance'); + expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + } + }); + + it('handles array of baseThemes without any cssLayerName', () => { + const appearance: Appearance = { + baseTheme: [ + { + __type: 'prebuilt_appearance' as const, + }, + { + __type: 'prebuilt_appearance' as const, + }, + ], + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBeUndefined(); + if (result?.baseTheme && Array.isArray(result.baseTheme)) { + expect(result.baseTheme).toHaveLength(2); + result.baseTheme.forEach(theme => { + expect(theme.__type).toBe('prebuilt_appearance'); + expect((theme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + }); + } + }); + + it('handles no baseTheme provided', () => { + const appearance: Appearance = { + cssLayerName: 'standalone-layer', + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBe('standalone-layer'); + expect(result?.baseTheme).toBeUndefined(); + }); + + it('handles undefined appearance', () => { + const result = processCssLayerNameExtraction(undefined); + + expect(result).toBeUndefined(); + }); + + it('preserves other appearance properties', () => { + const appearance: Appearance = { + variables: { colorPrimary: 'blue' }, + baseTheme: { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'theme-layer', + }, + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBe('theme-layer'); + expect(result?.variables?.colorPrimary).toBe('blue'); + if (result?.baseTheme && !Array.isArray(result.baseTheme)) { + expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + } + }); +}); diff --git a/packages/clerk-js/src/utils/appearance.ts b/packages/clerk-js/src/utils/appearance.ts new file mode 100644 index 00000000000..5aae032072b --- /dev/null +++ b/packages/clerk-js/src/utils/appearance.ts @@ -0,0 +1,67 @@ +import type { Appearance, BaseTheme } from '@clerk/types'; + +/** + * Extracts cssLayerName from baseTheme and moves it to appearance level. + * This is a pure function that can be tested independently. + */ +export function processCssLayerNameExtraction(appearance: Appearance | undefined): Appearance | undefined { + if (!appearance || typeof appearance !== 'object' || !('baseTheme' in appearance) || !appearance.baseTheme) { + return appearance; + } + + let cssLayerNameFromBaseTheme: string | undefined; + + if (Array.isArray(appearance.baseTheme)) { + // Handle array of themes - extract cssLayerName from each and use the first one found + appearance.baseTheme.forEach((theme: BaseTheme) => { + if (!cssLayerNameFromBaseTheme && theme.cssLayerName) { + cssLayerNameFromBaseTheme = theme.cssLayerName; + } + }); + + // Create array without cssLayerName properties + const processedBaseThemeArray = appearance.baseTheme.map((theme: BaseTheme) => { + const { cssLayerName, ...rest } = theme; + return rest; + }); + + // Use existing cssLayerName at appearance level, or fall back to one from baseTheme(s) + const finalCssLayerName = appearance.cssLayerName || cssLayerNameFromBaseTheme; + + const result = { + ...appearance, + baseTheme: processedBaseThemeArray, + }; + + if (finalCssLayerName) { + result.cssLayerName = finalCssLayerName; + } + + return result; + } else { + // Handle single theme + const singleTheme = appearance.baseTheme; + let cssLayerNameFromSingleTheme: string | undefined; + + if (singleTheme.cssLayerName) { + cssLayerNameFromSingleTheme = singleTheme.cssLayerName; + } + + // Create new theme without cssLayerName + const { cssLayerName, ...processedBaseTheme } = singleTheme; + + // Use existing cssLayerName at appearance level, or fall back to one from baseTheme + const finalCssLayerName = appearance.cssLayerName || cssLayerNameFromSingleTheme; + + const result = { + ...appearance, + baseTheme: processedBaseTheme, + }; + + if (finalCssLayerName) { + result.cssLayerName = finalCssLayerName; + } + + return result; + } +} diff --git a/packages/clerk-js/src/utils/index.ts b/packages/clerk-js/src/utils/index.ts index b3999d638b1..99f3c68eaae 100644 --- a/packages/clerk-js/src/utils/index.ts +++ b/packages/clerk-js/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './beforeUnloadTracker'; +export * from './appearance'; export * from './commerce'; export * from './completeSignUpFlow'; export * from './componentGuards'; diff --git a/packages/themes/src/createTheme.ts b/packages/themes/src/createTheme.ts index 55c99b06995..2c5e86f844e 100644 --- a/packages/themes/src/createTheme.ts +++ b/packages/themes/src/createTheme.ts @@ -13,5 +13,8 @@ interface CreateClerkThemeParams extends DeepPartial { export const experimental_createTheme = (appearance: Appearance): BaseTheme => { // Placeholder method that might hande more transformations in the future - return { ...appearance, __type: 'prebuilt_appearance' }; + return { + ...appearance, + __type: 'prebuilt_appearance', + }; }; diff --git a/packages/themes/src/themes/shadcn.ts b/packages/themes/src/themes/shadcn.ts index 19f78126abd..f03f61dd6af 100644 --- a/packages/themes/src/themes/shadcn.ts +++ b/packages/themes/src/themes/shadcn.ts @@ -1,6 +1,7 @@ import { experimental_createTheme } from '../createTheme'; export const shadcn = experimental_createTheme({ + cssLayerName: 'components', variables: { colorBackground: 'var(--card)', colorDanger: 'var(--destructive)', diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index c9448c313ad..4e39e02ebe8 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -806,7 +806,7 @@ export type Variables = { }; export type BaseThemeTaggedType = { __type: 'prebuilt_appearance' }; -export type BaseTheme = BaseThemeTaggedType; +export type BaseTheme = BaseThemeTaggedType & { cssLayerName?: string }; export type Theme = { /**