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/mosaic-stylex-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# compiled output
dist
dist-mosaic
tmp
out-tsc
out
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
build
coverage
dist
dist-mosaic
/packages/nextjs/examples
node_modules
package-lock.json
Expand Down
13 changes: 13 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import pluginJsxA11y from 'eslint-plugin-jsx-a11y';
import pluginPlaywright from 'eslint-plugin-playwright';
import pluginReact from 'eslint-plugin-react';
import pluginReactHooks from 'eslint-plugin-react-hooks';
import pluginStylex from '@stylexjs/eslint-plugin';
import pluginSimpleImportSort from 'eslint-plugin-simple-import-sort';
import pluginTurbo from 'eslint-plugin-turbo';
import pluginUnusedImports from 'eslint-plugin-unused-imports';
Expand Down Expand Up @@ -542,7 +543,19 @@ export default tseslint.config([
name: 'packages/ui/mosaic',
files: ['packages/ui/src/mosaic/**/*'],
ignores: ['packages/ui/src/mosaic/utils.ts', 'packages/ui/src/mosaic/__tests__/**'],
plugins: {
'@stylexjs': pluginStylex,
},
rules: {
'@stylexjs/enforce-extension': 'error',
'@stylexjs/no-legacy-contextual-styles': 'error',
'@stylexjs/no-lookahead-selectors': 'error',
'@stylexjs/no-nonstandard-styles': 'error',
'@stylexjs/no-conflicting-props': 'error',
'@stylexjs/no-unused': 'error',
'@stylexjs/sort-keys': 'error',
'@stylexjs/valid-shorthands': 'error',
'@stylexjs/valid-styles': 'error',
'no-restricted-syntax': [
'error',
{
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"@faker-js/faker": "^9.9.0",
"@octokit/rest": "^20.1.2",
"@playwright/test": "^1.56.1",
"@stylexjs/eslint-plugin": "0.19.0",
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^16.0.0",
Expand Down
33 changes: 33 additions & 0 deletions packages/swingset/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import stylexPlugin from '@stylexjs/unplugin/webpack';
import createMDX from '@next/mdx';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
Expand All @@ -6,6 +7,18 @@ import { fileURLToPath } from 'url';

const __dirname = fileURLToPath(new URL('.', import.meta.url));

// Match the isolated `@clerk/ui` mosaic build: pin browsers that natively support
// `light-dark()`/`oklch()` so lightningcss keeps the token colors verbatim instead of
// down-leveling them into an invalid polyfill. Encoding is (major << 16) | (minor << 8).
const version = (major, minor = 0) => (major << 16) | (minor << 8);
const stylexTargets = {
chrome: version(123),
edge: version(123),
firefox: version(120),
safari: version(17, 5),
ios_saf: version(17, 5),
};

const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm],
Expand Down Expand Up @@ -58,6 +71,26 @@ const nextConfig = {
excludeRawQuery(config.module.rules);
config.module.rules.push({ resourceQuery: /raw/, type: 'asset/source' });

// Swingset consumes Mosaic from source, so StyleX (`defineVars`/`create`/`props`) must be
// compiled here — otherwise the calls hit the runtime and throw. The plugin transforms the
// aliased `../ui/src/mosaic` files and appends the collected CSS into `globals.css`. Plain
// rules (`useCSSLayers: false`) so they coexist with swingset's own unlayered Tailwind CSS.
// The unplugin only compiles the StyleX *JS* here (turns `defineVars`/`create`/`props`
// into static atom references so nothing hits the runtime). It keeps SWC intact, so
// `next/font` and the Emotion transform keep working. The actual CSS is emitted by the
// `@stylexjs/postcss-plugin` into `globals.css` (`@stylex`), which is why this runs in
// extraction mode (no `runtimeInjection`) — the two share the same StyleX babel version
// and options, so the atom hashes match. The plugin's dev "no CSS asset" warning is
// expected and harmless: CSS delivery is postcss's job, not the bundler's.
config.plugins.push(
stylexPlugin({
dev: process.env.NODE_ENV !== 'production',
unstable_moduleResolution: { type: 'commonJS', rootDir: resolve(__dirname, '../ui') },
useCSSLayers: false,
lightningcssOptions: { targets: stylexTargets },
}),
);

Comment on lines +74 to +93

@coderabbitai coderabbitai Bot Jul 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== next.config.mjs ==\n'
sed -n '1,220p' packages/swingset/next.config.mjs

printf '\n== postcss.config.mjs ==\n'
sed -n '1,220p' packages/swingset/postcss.config.mjs

printf '\n== StyleX references ==\n'
rg -n "`@stylexjs`|stylexPlugin|runtimeInjection|useCSSLayers|globals.css|mosaic" packages/swingset packages/ui -g '!**/node_modules/**'

Repository: clerk/javascript

Length of output: 26821


🌐 Web query:

@stylexjs/unplugin webpack does it emit css runtimeInjection extraction mode postcss plugin

💡 Result:

The @stylexjs/unplugin for Webpack is designed primarily to aggregate StyleX styles at build time and append them to a CSS asset emitted by the bundler (e.g., using MiniCssExtractPlugin) [1][2][3]. It does not operate via a "runtime injection" extraction mode in production; rather, it consolidates styles into a static CSS file [2][3]. Key details regarding your query: 1. CSS Extraction: The plugin works by intercepting the build process to aggregate CSS and injecting it into an existing CSS asset produced by Webpack [2][3]. If no CSS asset is produced by your Webpack configuration, the plugin will emit a fallback stylesheet named stylex.css [2][3]. It is highly recommended to use a CSS extraction plugin like MiniCssExtractPlugin to ensure a proper injection target exists [1][4][3]. 2. Runtime Injection: While the underlying babel-plugin has a runtimeInjection option (typically used for development or specific client-side needs), it is not the intended "extraction mode" for production builds [5]. Production builds should rely on static CSS extraction to ensure better performance and deterministic output [3]. 3. PostCSS Plugin: StyleX maintains a separate package, @stylexjs/postcss-plugin, which is used for processing the CSS generated by StyleX [2]. It is not a replacement for the unplugin's core role in compiling and aggregating styles, but it can be part of the pipeline to process the aggregated CSS [2]. In summary, for Webpack, you should configure the @stylexjs/unplugin alongside an extraction plugin like MiniCssExtractPlugin to achieve static CSS output [1][4][3]. Avoid using runtime injection in production to prevent unnecessary overhead [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1,220p' packages/ui/tsdown.mosaic.config.mts

Repository: clerk/javascript

Length of output: 2082


🌐 Web query:

@stylexjs/unplugin webpack css extraction asset source stylex docs

💡 Result:

To extract CSS when using StyleX with Webpack, you should use the official @stylexjs/unplugin package [1][2][3]. The older @stylexjs/webpack-plugin is largely deprecated in favor of this universal bundler plugin [4][5]. The process for CSS extraction involves two main requirements: integrating the StyleX plugin and configuring a CSS extraction plugin (like MiniCssExtractPlugin) to provide an injection target [1][6][4]. 1. Configure the Plugin: Use stylex.webpack from @stylexjs/unplugin in your webpack.config.js [1][2]. It is recommended to enable useCSSLayers: true for better CSS specificity management [6][7]. 2. Provide an Extraction Target: StyleX aggregates the CSS generated by your application and automatically appends it to an existing CSS asset produced by your build [2][4][3]. You must include a CSS extraction plugin (e.g., MiniCssExtractPlugin) and ensure at least one CSS file is imported in your application code (typically a global stylesheet or entry-point CSS file) so that Webpack creates the necessary CSS asset [1][6][4]. If no CSS assets are emitted by your bundle, @stylexjs/unplugin will default to creating a standalone stylex.css file as a fallback [2][4][3]. Example configuration snippet: const stylex = require('@stylexjs/unplugin').default; const MiniCssExtractPlugin = require('mini-css-extract-plugin'); module.exports = { module: { rules: [ { test: /.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] }, ], }, plugins: [ stylex.webpack({ useCSSLayers: true }), new MiniCssExtractPlugin, ], }; You can use the cssInjectionTarget option (a function receiving the filename) within the stylex.webpack configuration if you need to specifically select which emitted CSS asset should receive the StyleX styles [4][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1,120p' packages/swingset/src/app/globals.css

Repository: clerk/javascript

Length of output: 4357


Use one StyleX CSS extraction path for Mosaic. packages/swingset/next.config.mjs and packages/swingset/postcss.config.mjs both process packages/ui/src/mosaic, and the webpack unplugin also emits CSS into the bundle. Keep one owner for CSS output and leave the other path to JS transforms only, or the same atoms can be emitted twice.

📍 Affects 2 files
  • packages/swingset/next.config.mjs#L74-L93 (this comment)
  • packages/swingset/postcss.config.mjs#L15-L38
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/swingset/next.config.mjs` around lines 74 - 93, Use a single StyleX
CSS extraction owner for Mosaic across packages/swingset/next.config.mjs:74-93
and packages/swingset/postcss.config.mjs:15-38. Update the stylexPlugin
configuration in next.config.mjs and the corresponding PostCSS configuration so
only one path emits CSS, while the other performs JS transforms only; preserve
matching StyleX options and avoid duplicate atom output.

Source: MCP tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional split: unplugin = JS transform, postcss-plugin = CSS. Checked unplugin webpack.js — it appends CSS only when a .css asset exists, else skips. Swingset runs next dev → no .css asset → unplugin no-ops, postcss sole CSS owner, no dup atoms. (prod build appends once more, but dev-only private tool + atomic CSS idempotent.) Leaving as-is.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexcarpenter, understood. The split is intentional: PostCSS is the CSS owner for Swingset development, while the webpack unplugin performs the StyleX JS transform. The production duplicate remains a known, accepted trade-off for this private dev tool, so I won’t press for a change here.


✏️ Learnings added
Learnt from: alexcarpenter
Repo: clerk/javascript PR: 9208
File: packages/swingset/next.config.mjs:74-93
Timestamp: 2026-07-21T21:29:23.020Z
Learning: In `packages/swingset`, the StyleX webpack unplugin intentionally performs the JavaScript transform for source-aliased Mosaic files, while `packages/swingset/postcss.config.mjs` owns CSS emission during `next dev`. Production may append equivalent atomic CSS once more; this is an accepted trade-off because Swingset is a private development tool.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic');
// Consume @clerk/headless primitives from source (no dist build needed), mirroring Mosaic.
// `/hooks` and `/utils` live outside `primitives/`, so alias them first (more specific wins).
Expand Down
9 changes: 8 additions & 1 deletion packages/swingset/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@clerk/headless": "workspace:*",
"@clerk/ui": "workspace:*",
"@emotion/react": "^11.11.1",
"@stylexjs/stylex": "0.19.0",
"@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand All @@ -28,16 +29,22 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@babel/plugin-syntax-jsx": "^7.27.1",
"@babel/preset-typescript": "^7.28.5",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.0.0",
"@stylexjs/babel-plugin": "0.19.0",
"@stylexjs/postcss-plugin": "0.19.0",
"@stylexjs/unplugin": "0.19.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"remark-gfm": "^4.0.0",
"shadcn": "^4.10.0",
"shiki": "^1.0.0",
"tailwindcss": "^4.0.0",
"typescript": "catalog:repo"
"typescript": "catalog:repo",
"unplugin": "^2.3.11"
}
}
36 changes: 36 additions & 0 deletions packages/swingset/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
import { createRequire } from 'module';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';

const require = createRequire(import.meta.url);
const __dirname = dirname(fileURLToPath(import.meta.url));

// StyleX CSS extraction. The `@stylexjs/postcss-plugin` scans the Mosaic source, runs the
// StyleX babel transform itself, and replaces the `@stylex;` directive in `globals.css` with
// the generated CSS (token `:root` defaults + atoms). This is the CSS half of the setup; the
// JS half is the unplugin in `next.config.mjs`. Both must use the SAME StyleX babel version
// and options (`dev`, `rootDir`) so the atom class hashes line up.
const uiRoot = resolve(__dirname, '../ui');

export default {
plugins: {
'@stylexjs/postcss-plugin': {
// `../ui/src/mosaic` is consumed from source (aliased), so point the scanner at it
// explicitly — auto-discovery only walks the package's own tree and direct deps.
include: [resolve(uiRoot, 'src/mosaic/**/*.{ts,tsx}')],
useCSSLayers: false,
babelConfig: {
babelrc: false,
configFile: false,
presets: [require('@babel/preset-typescript')],
plugins: [
require('@babel/plugin-syntax-jsx'),
[
require('@stylexjs/babel-plugin'),
{
dev: process.env.NODE_ENV !== 'production',
runtimeInjection: false,
unstable_moduleResolution: { type: 'commonJS', rootDir: uiRoot },
},
],
],
},
},
'@tailwindcss/postcss': {},
},
};
4 changes: 4 additions & 0 deletions packages/swingset/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@

@custom-variant dark (&:is(.dark *));

/* Replaced at build time by @stylexjs/postcss-plugin with the compiled Mosaic CSS
(token :root defaults + atoms). See postcss.config.mjs. */
@stylex;

body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
Expand Down
23 changes: 20 additions & 3 deletions packages/swingset/src/stories/button.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
/** @jsxImportSource @emotion/react */
import type { ButtonProps } from '@clerk/ui/mosaic/components/button';
import { Button, buttonRecipe } from '@clerk/ui/mosaic/components/button';
import { Button } from '@clerk/ui/mosaic/components/button';

import type { StoryMeta } from '@/lib/types';

// Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example
// renders a code footer with its function's source. See `StoryModule.__source`.
export { default as __source } from './button.stories?raw';

// StyleX has no runtime recipe to derive knobs from, so the variant surface is described
// here to drive the playground + prop table. Keys mirror `ButtonProps`.
export const meta: StoryMeta = {
group: 'Components',
title: 'Button',
source: 'packages/ui/src/mosaic/components/button.tsx',
styles: buttonRecipe,
source: 'packages/ui/src/mosaic/components/button/button.tsx',
styles: {
_variants: {
intent: { primary: {}, destructive: {} },
variant: { filled: {}, outline: {}, ghost: {} },
size: { sm: {}, md: {} },
shape: { default: {}, square: {}, circle: {} },
fullWidth: { true: {}, false: {} },
},
_defaultVariants: {
intent: 'primary',
variant: 'filled',
size: 'md',
shape: 'default',
fullWidth: false,
},
},
};

// Story functions accept Record<string,unknown> (knob values) and cast to ButtonProps.
Expand Down
16 changes: 15 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"author": "Clerk",
"sideEffects": [
"./src/utils/setWebpackChunkPublicPath.ts",
"./src/mosaic/**/*.stylex.ts",
"./dist-mosaic/styles.css",
"./register/*"
],
"type": "module",
Expand Down Expand Up @@ -51,6 +53,10 @@
"default": "./dist/themes/experimental.js"
},
"./themes/shadcn.css": "./dist/themes/shadcn.css",
"./styles.css": {
"types": "./styles.css.d.ts",
"default": "./dist-mosaic/styles.css"
},
"./register": {
"import": {
"types": "./register/index.d.ts",
Expand All @@ -66,12 +72,15 @@
"types": "dist/index.d.ts",
"files": [
"dist",
"dist-mosaic",
"styles.css.d.ts",
"register"
],
"scripts": {
"build": "pnpm build:umd && pnpm build:esm && pnpm check:no-rhc && pnpm type-check",
"build": "pnpm build:umd && pnpm build:esm && pnpm build:mosaic && pnpm check:no-rhc && pnpm type-check",
"build:analyze": "rspack build --config rspack.config.js --env production --env analyze",
"build:esm": "tsdown",
"build:mosaic": "tsdown --config tsdown.mosaic.config.mts",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"build:rsdoctor": "RSDOCTOR=true rspack build --config rspack.config.js --env production",
"build:umd": "rspack build --config rspack.config.js --env production",
"bundlewatch": "FORCE_COLOR=1 bundlewatch --config bundlewatch.config.json",
Expand Down Expand Up @@ -103,6 +112,7 @@
"@solana/wallet-adapter-base": "catalog:module-manager",
"@solana/wallet-adapter-react": "catalog:module-manager",
"@solana/wallet-standard": "catalog:module-manager",
"@stylexjs/stylex": "0.19.0",
"@swc/helpers": "catalog:repo",
"copy-to-clipboard": "3.3.3",
"core-js": "catalog:repo",
Expand All @@ -119,13 +129,17 @@
"@rspack/core": "catalog:rspack",
"@rspack/dev-server": "catalog:rspack",
"@rspack/plugin-react-refresh": "catalog:rspack",
"@stylexjs/eslint-plugin": "0.19.0",
"@stylexjs/rollup-plugin": "0.19.0",
"@stylexjs/unplugin": "0.19.0",
"@svgr/rollup": "^8.1.0",
"@svgr/webpack": "^6.5.1",
"@types/webpack-env": "^1.18.8",
"bundlewatch": "^0.4.2",
"cross-fetch": "^4.1.0",
"minimatch": "^10.2.5",
"tsdown": "catalog:repo",
"unplugin": "^2.3.11",
"webpack-merge": "^5.10.0"
},
"peerDependencies": {
Expand Down
74 changes: 74 additions & 0 deletions packages/ui/src/mosaic/__tests__/props.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';

import { mergeProps, themeProps } from '../props';

// The public styling contract lives here, not in any one component: the stable
// `.cl-<slot>` class, `data-<axis>` variant reflection, and the class/style merge
// order that lets a consumer's `className`/`style` win. Components just wire their
// props into these helpers, so this is where the contract is exhaustively pinned.

describe('themeProps', () => {
it('returns the stable slot class', () => {
expect(themeProps('button')).toEqual({ className: 'cl-button' });
});

it('reflects each variant axis as a data-<axis> attribute', () => {
expect(themeProps('button', { intent: 'destructive', variant: 'outline' })).toEqual({
className: 'cl-button',
'data-intent': 'destructive',
'data-variant': 'outline',
});
});

it('kebab-cases camelCase axis names', () => {
expect(themeProps('button', { fullWidth: 'yes' })).toHaveProperty('data-full-width', 'yes');
});

it('reflects a true boolean as an empty-string presence attribute', () => {
expect(themeProps('button', { fullWidth: true })).toHaveProperty('data-full-width', '');
});

it('skips false, null, and undefined axes', () => {
expect(themeProps('button', { a: false, b: null, c: undefined })).toEqual({ className: 'cl-button' });
});

it('stringifies numeric axis values', () => {
expect(themeProps('button', { size: 0 })).toHaveProperty('data-size', '0');
});
});

describe('mergeProps', () => {
it('concatenates className across bags, base first', () => {
const merged = mergeProps({ className: 'cl-button' }, { className: 'x1 x2' });
expect(merged.className).toBe('cl-button x1 x2');
});

it('accepts a trailing string as an appended className', () => {
const merged = mergeProps({ className: 'cl-button' }, { className: 'x1' }, 'consumer');
expect(merged.className).toBe('cl-button x1 consumer');
});

it('accepts a trailing object as a style and lets it win', () => {
const merged = mergeProps({ style: { marginTop: '2px', color: 'red' } }, undefined, { marginTop: '8px' });
expect(merged.style).toEqual({ marginTop: '8px', color: 'red' });
});

it('disambiguates the trailing className and style pair by position', () => {
const merged = mergeProps({ className: 'cl-button' }, undefined, 'consumer', { marginTop: '8px' });
expect(merged.className).toBe('cl-button consumer');
expect(merged.style).toEqual({ marginTop: '8px' });
});

it('treats a string second argument as a className', () => {
expect(mergeProps({ className: 'cl-button' }, 'consumer').className).toBe('cl-button consumer');
});

it('drops className entirely when nothing contributes one', () => {
expect(mergeProps({ 'data-intent': 'primary' }, {})).not.toHaveProperty('className');
});

it('preserves non-class/style props from both bags', () => {
const merged = mergeProps({ 'data-intent': 'primary' }, { role: 'button' });
expect(merged).toMatchObject({ 'data-intent': 'primary', role: 'button' });
});
});
Loading
Loading