From f63c46e15f6a678e9fa895e6b27f611c9ef1ccd9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 6 May 2026 14:27:54 -0400 Subject: [PATCH 1/3] fix(ui): Ensure cssLayerName from appearance is preserved during updateProps --- packages/ui/src/Components.tsx | 5 + .../extractCssLayerNameFromAppearance.test.ts | 109 ++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 packages/ui/src/utils/__tests__/extractCssLayerNameFromAppearance.test.ts diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx index 9b290c5ca9a..42c4db01fc6 100644 --- a/packages/ui/src/Components.tsx +++ b/packages/ui/src/Components.tsx @@ -397,6 +397,11 @@ const Components = (props: ComponentsProps) => { } } + // Extract cssLayerName from theme if present and move it to appearance level + if (restProps.appearance) { + restProps = { ...restProps, appearance: extractCssLayerNameFromAppearance(restProps.appearance) }; + } + setState(s => ({ ...s, ...restProps, options: { ...s.options, ...restProps.options } })); }; diff --git a/packages/ui/src/utils/__tests__/extractCssLayerNameFromAppearance.test.ts b/packages/ui/src/utils/__tests__/extractCssLayerNameFromAppearance.test.ts new file mode 100644 index 00000000000..65a63f81408 --- /dev/null +++ b/packages/ui/src/utils/__tests__/extractCssLayerNameFromAppearance.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { extractCssLayerNameFromAppearance } from '../extractCssLayerNameFromAppearance'; + +describe('extractCssLayerNameFromAppearance', () => { + it('promotes cssLayerName from a single theme to the appearance level', () => { + const theme = { + name: 'shadcn', + cssLayerName: 'components', + variables: {}, + elements: {}, + __type: 'prebuilt_appearance' as const, + }; + + const result = extractCssLayerNameFromAppearance({ theme }); + + expect(result?.cssLayerName).toBe('components'); + }); + + it('promotes cssLayerName from a theme array to the appearance level', () => { + const theme = { + name: 'shadcn', + cssLayerName: 'components', + variables: {}, + elements: {}, + __type: 'prebuilt_appearance' as const, + }; + + const result = extractCssLayerNameFromAppearance({ theme: [theme] }); + + expect(result?.cssLayerName).toBe('components'); + }); + + it('preserves explicit cssLayerName on appearance over theme cssLayerName', () => { + const theme = { + name: 'shadcn', + cssLayerName: 'components', + variables: {}, + elements: {}, + __type: 'prebuilt_appearance' as const, + }; + + const result = extractCssLayerNameFromAppearance({ theme, cssLayerName: 'custom' }); + + expect(result?.cssLayerName).toBe('custom'); + }); + + it('returns appearance unchanged when no theme is present', () => { + const appearance = { cssLayerName: 'custom' }; + const result = extractCssLayerNameFromAppearance(appearance); + + expect(result).toEqual(appearance); + }); + + it('returns undefined for undefined input', () => { + expect(extractCssLayerNameFromAppearance(undefined)).toBeUndefined(); + }); + + describe('Components.updateProps state merge', () => { + // This replicates the state merge in Components.tsx updateProps handler: + // setState(s => ({ ...s, ...restProps, options: { ...s.options, ...restProps.options } })) + // + // When ClerkProvider re-renders, it calls __internal_updateProps({ appearance: { theme: shadcn } }). + // This arrives in updateProps as restProps = { appearance: { theme: shadcn } }. + // The bug: cssLayerName buried in theme is not promoted to appearance level, + // so StyleCacheProvider never wraps Clerk CSS in @layer. + + function updatePropsStateMerge(currentState: Record, restProps: Record) { + // This is the exact logic from Components.tsx line 400 (without fix) + return { ...currentState, ...restProps, options: { ...currentState.options, ...restProps.options } }; + } + + function updatePropsStateMergeFixed(currentState: Record, restProps: Record) { + if (restProps.appearance) { + restProps = { ...restProps, appearance: extractCssLayerNameFromAppearance(restProps.appearance) }; + } + return { ...currentState, ...restProps, options: { ...currentState.options, ...restProps.options } }; + } + + const theme = { + name: 'shadcn', + cssLayerName: 'components', + variables: {}, + elements: {}, + __type: 'prebuilt_appearance' as const, + }; + + const initialState = { + appearance: { theme, cssLayerName: 'components' }, // after mountComponentRenderer extraction + options: {}, + }; + + it('BUG: updateProps overwrites extracted cssLayerName with raw appearance from ClerkProvider', () => { + // ClerkProvider sends raw appearance (cssLayerName only inside theme, not at top level) + const restProps = { appearance: { theme } }; + const newState = updatePropsStateMerge(initialState, restProps); + + // cssLayerName is lost — it's only inside theme, not at the appearance level + expect(newState.appearance.cssLayerName).toBeUndefined(); + }); + + it('FIX: updateProps extracts cssLayerName from theme before merging into state', () => { + const restProps = { appearance: { theme } }; + const newState = updatePropsStateMergeFixed(initialState, restProps); + + expect(newState.appearance.cssLayerName).toBe('components'); + }); + }); +}); From 556b0f6f98de67c3b583bfb9b0f3bf7074968d12 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 6 May 2026 14:30:07 -0400 Subject: [PATCH 2/3] add changeset --- .changeset/fix-css-layer-name-update-props.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-css-layer-name-update-props.md diff --git a/.changeset/fix-css-layer-name-update-props.md b/.changeset/fix-css-layer-name-update-props.md new file mode 100644 index 00000000000..5961b5f6ea3 --- /dev/null +++ b/.changeset/fix-css-layer-name-update-props.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Fix `cssLayerName` from theme not being applied after initial mount. When `ClerkProvider` re-renders, the `updateProps` handler overwrote the extracted `cssLayerName` with the raw appearance object, causing Clerk's runtime CSS to not be wrapped in `@layer`. This broke Tailwind utility overrides when using themes like `shadcn` that set `cssLayerName: 'components'`. From a675237154110a2360d0243d0c342e5d12b5b413 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 6 May 2026 14:37:48 -0400 Subject: [PATCH 3/3] Update extractCssLayerNameFromAppearance.test.ts --- .../extractCssLayerNameFromAppearance.test.ts | 61 ++++++++----------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/packages/ui/src/utils/__tests__/extractCssLayerNameFromAppearance.test.ts b/packages/ui/src/utils/__tests__/extractCssLayerNameFromAppearance.test.ts index 65a63f81408..93146b3cd38 100644 --- a/packages/ui/src/utils/__tests__/extractCssLayerNameFromAppearance.test.ts +++ b/packages/ui/src/utils/__tests__/extractCssLayerNameFromAppearance.test.ts @@ -56,27 +56,7 @@ describe('extractCssLayerNameFromAppearance', () => { expect(extractCssLayerNameFromAppearance(undefined)).toBeUndefined(); }); - describe('Components.updateProps state merge', () => { - // This replicates the state merge in Components.tsx updateProps handler: - // setState(s => ({ ...s, ...restProps, options: { ...s.options, ...restProps.options } })) - // - // When ClerkProvider re-renders, it calls __internal_updateProps({ appearance: { theme: shadcn } }). - // This arrives in updateProps as restProps = { appearance: { theme: shadcn } }. - // The bug: cssLayerName buried in theme is not promoted to appearance level, - // so StyleCacheProvider never wraps Clerk CSS in @layer. - - function updatePropsStateMerge(currentState: Record, restProps: Record) { - // This is the exact logic from Components.tsx line 400 (without fix) - return { ...currentState, ...restProps, options: { ...currentState.options, ...restProps.options } }; - } - - function updatePropsStateMergeFixed(currentState: Record, restProps: Record) { - if (restProps.appearance) { - restProps = { ...restProps, appearance: extractCssLayerNameFromAppearance(restProps.appearance) }; - } - return { ...currentState, ...restProps, options: { ...currentState.options, ...restProps.options } }; - } - + it('persists cssLayerName when appearance is re-extracted after an updateProps-style state merge', () => { const theme = { name: 'shadcn', cssLayerName: 'components', @@ -85,25 +65,32 @@ describe('extractCssLayerNameFromAppearance', () => { __type: 'prebuilt_appearance' as const, }; - const initialState = { - appearance: { theme, cssLayerName: 'components' }, // after mountComponentRenderer extraction - options: {}, - }; + // Initial mount: cssLayerName is extracted to appearance level + const initialAppearance = extractCssLayerNameFromAppearance({ theme }); + expect(initialAppearance?.cssLayerName).toBe('components'); + + // ClerkProvider re-renders and sends raw appearance (cssLayerName only inside theme) + const rawAppearance = { theme }; - it('BUG: updateProps overwrites extracted cssLayerName with raw appearance from ClerkProvider', () => { - // ClerkProvider sends raw appearance (cssLayerName only inside theme, not at top level) - const restProps = { appearance: { theme } }; - const newState = updatePropsStateMerge(initialState, restProps); + // updateProps must re-extract before merging into state + const reExtracted = extractCssLayerNameFromAppearance(rawAppearance); + expect(reExtracted?.cssLayerName).toBe('components'); + }); - // cssLayerName is lost — it's only inside theme, not at the appearance level - expect(newState.appearance.cssLayerName).toBeUndefined(); - }); + it('respects a new cssLayerName passed via updateProps', () => { + const theme = { + name: 'shadcn', + cssLayerName: 'components', + variables: {}, + elements: {}, + __type: 'prebuilt_appearance' as const, + }; - it('FIX: updateProps extracts cssLayerName from theme before merging into state', () => { - const restProps = { appearance: { theme } }; - const newState = updatePropsStateMergeFixed(initialState, restProps); + // User changes cssLayerName at the appearance level + const updatedAppearance = { theme, cssLayerName: 'utilities' }; + const result = extractCssLayerNameFromAppearance(updatedAppearance); - expect(newState.appearance.cssLayerName).toBe('components'); - }); + // Explicit appearance-level cssLayerName takes precedence over theme + expect(result?.cssLayerName).toBe('utilities'); }); });