Skip to content

feat(clerk-js,types): Add experimental css layer name option #5552

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
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
6 changes: 6 additions & 0 deletions .changeset/metal-phones-like.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Introduce experimental `cssLayerName` option to allow users to opt into CSS layers.
5 changes: 4 additions & 1 deletion packages/clerk-js/src/ui/lazyModules/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ type LazyProvidersProps = React.PropsWithChildren<{ clerk: any; environment: any

export const LazyProviders = (props: LazyProvidersProps) => {
return (
<StyleCacheProvider nonce={props.options.nonce}>
<StyleCacheProvider
nonce={props.options.nonce}
cssLayerName={props.options.experimental?.cssLayerName}
>
<CoreClerkContextWrapper clerk={props.clerk}>
<EnvironmentProvider value={props.environment}>
<OptionsProvider value={props.options}>{props.children}</OptionsProvider>
Expand Down
65 changes: 60 additions & 5 deletions packages/clerk-js/src/ui/styledSystem/StyleCacheProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,80 @@
// eslint-disable-next-line no-restricted-imports
import type { StylisPlugin } from '@emotion/cache';
// eslint-disable-next-line no-restricted-imports
import createCache from '@emotion/cache';
// eslint-disable-next-line no-restricted-imports
import { CacheProvider } from '@emotion/react';
import React, { useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';

/**
* A Stylis plugin that wraps CSS rules in a CSS layer
* @param layerName - The name of the CSS layer to wrap the styles in
* @returns A Stylis plugin function
*/
export const wrapInLayer: (layerName: string) => StylisPlugin = layerName => node => {
// if we're not at the root of a <style> tag, leave the tree intact
if (node.parent) return;

const el = document.querySelector('style#cl-style-insertion-point');
// if we're at the root, replace node with `@layer layerName { node }`
const child = { ...node, parent: node, root: node };
Object.assign(node, {
children: [child],
length: 6,
parent: null,
props: [layerName],
return: '',
root: null,
type: '@layer',
value: `@layer ${layerName}`,
});
};

/**
* Finds the appropriate insertion point for Emotion styles in the DOM
* @returns The HTMLElement to use as insertion point, or undefined if none found
*/
export const getInsertionPoint = (): HTMLElement | undefined => {
try {
const metaTag = document.querySelector('meta[name="emotion-insertion-point"]');
if (metaTag) {
return metaTag as HTMLElement;
}
return document.querySelector('style#cl-style-insertion-point') as HTMLElement;
} catch (error) {
console.warn('Failed to find Emotion insertion point:', error);
return undefined;
}
};

type StyleCacheProviderProps = React.PropsWithChildren<{
/** Optional nonce value for CSP (Content Security Policy) */
nonce?: string;
/** Optional CSS layer name to wrap styles in */
cssLayerName?: string;
}>;

/**
* Provides an Emotion cache configuration for styling with support for CSS layers and CSP nonce
* @param props - Component props
* @returns A CacheProvider component with configured Emotion cache
*/
export const StyleCacheProvider = (props: StyleCacheProviderProps) => {
const [insertionPoint, setInsertionPoint] = useState<HTMLElement | undefined>();

useEffect(() => {
setInsertionPoint(getInsertionPoint());
}, []);

const cache = useMemo(
() =>
createCache({
key: 'cl-internal',
prepend: !el,
insertionPoint: el ? (el as HTMLElement) : undefined,
prepend: !insertionPoint,
insertionPoint: insertionPoint ?? undefined,
nonce: props.nonce,
stylisPlugins: props.cssLayerName ? [wrapInLayer(props.cssLayerName)] : undefined,
}),
[props.nonce],
[props.nonce, props.cssLayerName, insertionPoint],
);

return <CacheProvider value={cache}>{props.children}</CacheProvider>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// eslint-disable-next-line no-restricted-imports
import type { StylisElement } from '@emotion/cache';

import { getInsertionPoint, wrapInLayer } from '../StyleCacheProvider';

// Mock the StylisPlugin type
type MockStylisElement = Partial<StylisElement> & {
type: string;
props: string[];
children: MockStylisElement[];
parent: MockStylisElement | null;
root: MockStylisElement | null;
length: number;
value: string;
return: string;
line: number;
column: number;
};

describe('wrapInLayer', () => {
it('wraps CSS rules in a CSS layer', () => {
const node: MockStylisElement = {
type: 'rule',
props: ['body'],
children: [],
parent: null,
root: null,
length: 0,
value: '',
return: '',
line: 1,
column: 1,
};

const plugin = wrapInLayer('test-layer');
// @ts-expect-error - We're mocking the StylisPlugin type
plugin(node);

expect(node.type).toBe('@layer');
expect(node.props).toEqual(['test-layer']);
expect(node.children).toHaveLength(1);
const child = node.children[0];
expect(child.props).toEqual(['body']);
});

it('does not wrap if node has a parent', () => {
const node: MockStylisElement = {
type: 'rule',
props: ['body'],
children: [],
parent: { type: 'parent' } as MockStylisElement,
root: null,
length: 0,
value: '',
return: '',
line: 1,
column: 1,
};

const originalNode = { ...node };
const plugin = wrapInLayer('test-layer');
// @ts-expect-error - We're mocking the StylisPlugin type
plugin(node);

expect(node).toEqual(originalNode);
});
});

describe('getInsertionPoint', () => {
beforeEach(() => {
document.head.innerHTML = '';
});

it('returns meta tag if found', () => {
const meta = document.createElement('meta');
meta.setAttribute('name', 'emotion-insertion-point');
document.head.appendChild(meta);

const result = getInsertionPoint();
expect(result).toBe(meta);
});

it('returns style tag if found', () => {
const style = document.createElement('style');
style.id = 'cl-style-insertion-point';
document.head.appendChild(style);

const result = getInsertionPoint();
expect(result).toBe(style);
});

it('returns null if no insertion point found', () => {
const result = getInsertionPoint();
expect(result).toBeNull();
});
});
16 changes: 16 additions & 0 deletions packages/types/src/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,22 @@ export type ClerkOptions = PendingSessionOptions &
*/
rethrowOfflineNetworkErrors: boolean;
commerce: boolean;
/**
* The name of the CSS layer to use for Clerk components.
* @example
* ```tsx
* <ClerkProvider cssLayerName="components">
* <App />
* </ClerkProvider>
* ```
* This will wrap all Clerk styles in a `components` CSS layer to work with tools like Tailwind CSS V4.
*```css
* @layer components {
* ... clerk styles ...
* }
*```
*/
cssLayerName?: string;
},
Record<string, any>
>;
Expand Down