From cc408576c2ecc9695be339ef06ac2e4308297df9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 4 Aug 2026 09:54:20 -0400 Subject: [PATCH 01/14] feat(nextjs): export Mosaic UserButton from an experimental subpath Adds `@clerk/nextjs/experimental/mosaic` (via `@clerk/react` and `@clerk/ui`) so the Mosaic UserButton can mount directly in a host app's tree, plus the matching `experimental/mosaic/styles.css`. The `@clerk/ui` mosaic entry is now a client boundary in whole (the bundle collapses the per-file directives) and is guarded against Emotion creeping back in, which meant replacing the UserButton trigger skeleton with StyleX. --- .changeset/mosaic-experimental-export.md | 21 ++++++++++++++++ .claude/skills/mosaic/references/stylex.md | 4 ++-- packages/nextjs/package.json | 11 +++++++-- packages/nextjs/src/experimental/mosaic.ts | 16 +++++++++++++ packages/react/package.json | 17 ++++++++++--- packages/react/src/experimental/mosaic.ts | 14 +++++++++++ packages/react/tsdown.config.mts | 1 + .../swingset/src/stories/menu.component.mdx | 4 ++-- .../src/stories/popover.component.mdx | 4 ++-- packages/ui/package.json | 12 +++++++--- .../ui/scripts/check-mosaic-emotion-free.mjs | 23 ++++++++++++++++++ packages/ui/src/mosaic/styles/index.ts | 4 ++++ packages/ui/styles.css.d.ts | 2 +- packages/ui/tsdown.mosaic.config.mts | 6 ++++- pnpm-lock.yaml | 15 +++++++----- scripts/copy-mosaic-styles.mjs | 24 +++++++++++++++++++ 16 files changed, 156 insertions(+), 22 deletions(-) create mode 100644 .changeset/mosaic-experimental-export.md create mode 100644 packages/nextjs/src/experimental/mosaic.ts create mode 100644 packages/react/src/experimental/mosaic.ts create mode 100644 packages/ui/scripts/check-mosaic-emotion-free.mjs create mode 100644 scripts/copy-mosaic-styles.mjs diff --git a/.changeset/mosaic-experimental-export.md b/.changeset/mosaic-experimental-export.md new file mode 100644 index 00000000000..35b2a1dc9be --- /dev/null +++ b/.changeset/mosaic-experimental-export.md @@ -0,0 +1,21 @@ +--- +'@clerk/nextjs': minor +'@clerk/react': minor +'@clerk/ui': minor +--- + +Add an experimental subpath for Mosaic components that mount directly in your app's tree rather than being rendered by clerk-js. `UserButton` is the first one. It reads Clerk through hooks, so a `ClerkProvider` above it is all it needs: + +```tsx +import { UserButton } from '@clerk/nextjs/experimental/mosaic'; +``` + +Pair it with the stylesheet, which carries the design tokens and every component rule: + +```css +@import '@clerk/nextjs/experimental/mosaic/styles.css' layer(clerk); +``` + +The surface and the components behind it will change without a major version while they are experimental. + +In `@clerk/ui`, the Mosaic stylesheet moves from `@clerk/ui/styles.css` to `@clerk/ui/experimental/mosaic/styles.css` to sit alongside the components it styles. Update the import if you were using it. diff --git a/.claude/skills/mosaic/references/stylex.md b/.claude/skills/mosaic/references/stylex.md index 5c6675e468a..28c16ec4e5f 100644 --- a/.claude/skills/mosaic/references/stylex.md +++ b/.claude/skills/mosaic/references/stylex.md @@ -637,8 +637,8 @@ export interface PopoverPopupProps extends MosaicComponentProps<'div'> { … } - **Published** (`build:mosaic` → `@stylexjs/rollup-plugin`): compiles the `styles/index.ts` barrel into `dist-mosaic/styles.css`, exported as - `@clerk/ui/styles.css`. Consumers choose the cascade layer at import: - `@import '@clerk/ui/styles.css' layer(components)`. + `@clerk/ui/experimental/mosaic/styles.css`. Consumers choose the cascade layer at import: + `@import '@clerk/ui/experimental/mosaic/styles.css' layer(components)`. - **Swingset** (source-consumed): `@stylexjs/unplugin/webpack` in `next.config` transforms StyleX **JS only** (calls → static atoms; SWC/Emotion untouched); `@stylexjs/postcss-plugin` extracts the **CSS** by replacing `@stylex;` in diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 6dbac746406..9f5a2f1ded4 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -55,6 +55,12 @@ "import": "./dist/esm/experimental.js", "require": "./dist/cjs/experimental.js" }, + "./experimental/mosaic": { + "types": "./dist/types/experimental/mosaic.d.ts", + "import": "./dist/esm/experimental/mosaic.js", + "require": "./dist/cjs/experimental/mosaic.js" + }, + "./experimental/mosaic/styles.css": "./dist/experimental/mosaic/styles.css", "./legacy": { "types": "./dist/types/legacy.d.ts", "import": "./dist/esm/legacy.js", @@ -70,7 +76,7 @@ "webhooks" ], "scripts": { - "build": "pnpm clean && tsup", + "build": "pnpm clean && tsup && node ../../scripts/copy-mosaic-styles.mjs dist/experimental/mosaic/styles.css", "build:declarations": "tsc -p tsconfig.declarations.json", "clean": "rimraf ./dist", "dev": "tsup --watch", @@ -78,7 +84,7 @@ "format": "node ../../scripts/format-package.mjs", "format:check": "node ../../scripts/format-package.mjs --check", "lint": "eslint src", - "lint:attw": "attw --pack . --profile node16 --ignore-rules unexpected-module-syntax", + "lint:attw": "attw --pack . --exclude-entrypoints experimental/mosaic/styles.css --profile node16 --ignore-rules unexpected-module-syntax", "lint:publint": "publint", "test": "vitest run", "test:watch": "vitest watch" @@ -91,6 +97,7 @@ "tslib": "catalog:repo" }, "devDependencies": { + "@clerk/ui": "workspace:*", "crypto-es": "^2.1.0", "next": "15.5.19" }, diff --git a/packages/nextjs/src/experimental/mosaic.ts b/packages/nextjs/src/experimental/mosaic.ts new file mode 100644 index 00000000000..27da5d32e99 --- /dev/null +++ b/packages/nextjs/src/experimental/mosaic.ts @@ -0,0 +1,16 @@ +'use client'; + +/** + * Mosaic components mounted directly in the host app's tree, rather than through clerk-js. They + * read Clerk via hooks, so a `ClerkProvider` above them is all they need. + * + * Pair with the stylesheet, which carries the design tokens and every component rule: + * + * ```css + * @import '@clerk/nextjs/experimental/mosaic/styles.css' layer(clerk); + * ``` + * + * @experimental The surface and the components behind it are subject to change. + */ +export { UserButton } from '@clerk/react/experimental/mosaic'; +export type { UserButtonProps } from '@clerk/react/experimental/mosaic'; diff --git a/packages/react/package.json b/packages/react/package.json index 94dd73428c0..444663ee186 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -63,6 +63,17 @@ "default": "./dist/experimental.cjs" } }, + "./experimental/mosaic": { + "import": { + "types": "./dist/experimental/mosaic.d.mts", + "default": "./dist/experimental/mosaic.mjs" + }, + "require": { + "types": "./dist/experimental/mosaic.d.cts", + "default": "./dist/experimental/mosaic.cjs" + } + }, + "./experimental/mosaic/styles.css": "./dist/experimental/mosaic/styles.css", "./legacy": { "import": { "types": "./dist/legacy.d.mts", @@ -88,25 +99,25 @@ "dist" ], "scripts": { - "build": "tsdown", + "build": "tsdown && node ../../scripts/copy-mosaic-styles.mjs dist/experimental/mosaic/styles.css", "clean": "rimraf ./dist", "dev": "tsdown --watch", "dev:pub": "pnpm dev --env.publish", "format": "node ../../scripts/format-package.mjs", "format:check": "node ../../scripts/format-package.mjs --check", "lint": "eslint src", - "lint:attw": "attw --pack . --profile node16", + "lint:attw": "attw --pack . --exclude-entrypoints experimental/mosaic/styles.css --profile node16", "lint:publint": "publint", "test": "vitest run", "test:watch": "vitest watch" }, "dependencies": { "@clerk/shared": "workspace:^", + "@clerk/ui": "workspace:^", "tslib": "catalog:repo" }, "devDependencies": { "@clerk/localizations": "workspace:*", - "@clerk/ui": "workspace:*", "@types/semver": "^7.7.1", "semver": "^7.8.5", "yaml": "^2.9.0" diff --git a/packages/react/src/experimental/mosaic.ts b/packages/react/src/experimental/mosaic.ts new file mode 100644 index 00000000000..6ffe79f4533 --- /dev/null +++ b/packages/react/src/experimental/mosaic.ts @@ -0,0 +1,14 @@ +/** + * Mosaic components mounted directly in the host app's tree, rather than through clerk-js. They + * read Clerk via `@clerk/shared/react` hooks, so a `ClerkProvider` above them is all they need. + * + * Pair with the stylesheet, which carries the design tokens and every component rule: + * + * ```css + * @import '@clerk/react/experimental/mosaic/styles.css' layer(clerk); + * ``` + * + * @experimental The surface and the components behind it are subject to change. + */ +export { UserButton } from '@clerk/ui/experimental/mosaic'; +export type { UserButtonProps } from '@clerk/ui/experimental/mosaic'; diff --git a/packages/react/tsdown.config.mts b/packages/react/tsdown.config.mts index 3b2d3521d6c..7f101bb8730 100644 --- a/packages/react/tsdown.config.mts +++ b/packages/react/tsdown.config.mts @@ -63,6 +63,7 @@ export default defineConfig((overrideOptions: Options) => { internal: 'src/internal.ts', errors: 'src/errors.ts', experimental: 'src/experimental.ts', + 'experimental/mosaic': 'src/experimental/mosaic.ts', legacy: 'src/legacy.ts', types: 'src/types/index.ts', }, diff --git a/packages/swingset/src/stories/menu.component.mdx b/packages/swingset/src/stories/menu.component.mdx index 0a1e3081e37..26a8100af14 100644 --- a/packages/swingset/src/stories/menu.component.mdx +++ b/packages/swingset/src/stories/menu.component.mdx @@ -220,10 +220,10 @@ const [open, setOpen] = useState(false); The Mosaic menu is themed with **StyleX**. Each styled part carries a stable `.cl-` class (the slots above) alongside the StyleX atoms. Consumers never target the hashed atomic classes — override by targeting the `.cl-*` slot from a CSS layer that wins -over `@clerk/ui/styles.css`: +over `@clerk/ui/experimental/mosaic/styles.css`: ```css -@import '@clerk/ui/styles.css' layer(components); +@import '@clerk/ui/experimental/mosaic/styles.css' layer(components); @layer overrides { .cl-menu-popup { diff --git a/packages/swingset/src/stories/popover.component.mdx b/packages/swingset/src/stories/popover.component.mdx index 52350546230..8a457809590 100644 --- a/packages/swingset/src/stories/popover.component.mdx +++ b/packages/swingset/src/stories/popover.component.mdx @@ -189,10 +189,10 @@ them through your own typography (`Heading`, `Text`) inside the surface. The Mosaic popover is themed with **StyleX**. Each styled part carries a stable `.cl-` class (the slots in the table above) alongside the StyleX atoms. Consumers never target the hashed atomic classes — override by targeting the `.cl-*` slot from a -CSS layer that wins over `@clerk/ui/styles.css`: +CSS layer that wins over `@clerk/ui/experimental/mosaic/styles.css`: ```css -@import '@clerk/ui/styles.css' layer(components); +@import '@clerk/ui/experimental/mosaic/styles.css' layer(components); @layer overrides { .cl-popover-popup[data-size='lg'] { diff --git a/packages/ui/package.json b/packages/ui/package.json index 0dbcd51fb81..cc47970b430 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -57,7 +57,12 @@ "default": "./dist/experimental/index.js" }, "./themes/shadcn.css": "./dist/themes/shadcn.css", - "./styles.css": { + "./experimental/mosaic": { + "types": "./dist-mosaic/index.d.ts", + "import": "./dist-mosaic/index.js", + "default": "./dist-mosaic/index.js" + }, + "./experimental/mosaic/styles.css": { "types": "./styles.css.d.ts", "default": "./dist-mosaic/styles.css" }, @@ -81,7 +86,7 @@ "register" ], "scripts": { - "build": "pnpm build:umd && pnpm build:esm && pnpm build:mosaic && pnpm check:no-rhc && pnpm type-check", + "build": "pnpm build:umd && pnpm build:esm && pnpm build:mosaic && pnpm check:no-rhc && pnpm check:no-emotion && 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", @@ -89,6 +94,7 @@ "build:umd": "rspack build --config rspack.config.js --env production", "bundlewatch": "FORCE_COLOR=1 bundlewatch --config bundlewatch.config.json", "bundlewatch:fix": "node bundlewatch-fix.mjs", + "check:no-emotion": "node scripts/check-mosaic-emotion-free.mjs", "check:no-rhc": "node ../../scripts/search-for-rhc.mjs directory dist/no-rhc", "clean": "rimraf ./dist", "dev": "rspack serve --config rspack.config.js", @@ -107,6 +113,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "@clerk/headless": "workspace:^", "@clerk/localizations": "workspace:^", "@clerk/shared": "workspace:^", "@emotion/cache": "11.11.0", @@ -126,7 +133,6 @@ "qrcode.react": "4.2.0" }, "devDependencies": { - "@clerk/headless": "workspace:^", "@floating-ui/react-dom": "^2.1.8", "@rsdoctor/rspack-plugin": "^1.5.9", "@rspack/cli": "catalog:rspack", diff --git a/packages/ui/scripts/check-mosaic-emotion-free.mjs b/packages/ui/scripts/check-mosaic-emotion-free.mjs new file mode 100644 index 00000000000..8d4e07f4020 --- /dev/null +++ b/packages/ui/scripts/check-mosaic-emotion-free.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +/** + * The `build:mosaic` entry is published as `@clerk/ui/experimental/mosaic` and mounted directly in + * host apps, so it must stay Emotion-free: pulling `@emotion/react` in ships a second styling + * runtime to every consumer. Nothing about the barrel enforces that — one legacy component reached + * from the graph (an `sx` prop, a `Box`, a `keyframes`) drags it back in silently. This fails the + * build instead. + */ + +import { readFileSync } from 'node:fs'; + +const BUNDLE = new URL('../dist-mosaic/index.js', import.meta.url); + +const source = readFileSync(BUNDLE, 'utf8'); +const offenders = source.split('\n').filter(line => line.includes('@emotion')); + +if (offenders.length > 0) { + console.error(`Found Emotion in the Mosaic build output (dist-mosaic/index.js):\n${offenders.join('\n')}`); + process.exit(1); +} + +console.log('✅ No Emotion found in the Mosaic build output'); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 1c47fa8110e..e04aad7e4c1 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -31,6 +31,10 @@ export type { AlertDialogTriggerProps, AlertDialogViewportProps, } from '../components/alert-dialog'; + +export { UserButton } from '../user-button/user-button'; +export type { UserButtonProps } from '../user-button/user-button'; + export { Avatar } from '../components/avatar'; export type { AvatarProps, AvatarImageProps, AvatarFallbackProps, AvatarIconProps } from '../components/avatar'; export { Badge } from '../components/badge'; diff --git a/packages/ui/styles.css.d.ts b/packages/ui/styles.css.d.ts index 2f6203bca8b..2f6934a024b 100644 --- a/packages/ui/styles.css.d.ts +++ b/packages/ui/styles.css.d.ts @@ -1,3 +1,3 @@ -// Type stub so `import '@clerk/ui/styles.css'` type-checks. The StyleX build emits +// Type stub so `import '@clerk/ui/experimental/mosaic/styles.css'` type-checks. The StyleX build emits // the real stylesheet to dist-mosaic/styles.css; this side-effect import has no value. export {}; diff --git a/packages/ui/tsdown.mosaic.config.mts b/packages/ui/tsdown.mosaic.config.mts index fccff71ba8b..2bf2ca6a723 100644 --- a/packages/ui/tsdown.mosaic.config.mts +++ b/packages/ui/tsdown.mosaic.config.mts @@ -9,7 +9,7 @@ import { mosaicLightningCssTargets } from './stylex-lightningcss.config.mjs'; // // `useCSSLayers` wraps StyleX's atomic rules in `@layer priorityN` for correct // intra-StyleX precedence; consumers import the sheet into a layer they control -// (`@import '@clerk/ui/styles.css' layer(components)`), under which those nest +// (`@import '@clerk/ui/experimental/mosaic/styles.css' layer(components)`), under which those nest // cleanly, and override from a later layer. export default defineConfig({ entry: ['./src/mosaic/styles/index.ts'], @@ -26,6 +26,10 @@ export default defineConfig({ // extract `styles.css`, and only that file is exported from the package — so there is nothing // to gain from pulling the primitives and their deps into a bundle nobody imports. external: ['react', 'react-dom', '@stylexjs/stylex', /^@clerk\/headless/], + // The bundle collapses every module into one, so the per-file `'use client'` directives are lost. + // Everything here is interactive and hook-driven, so the entry is a client boundary in whole — + // without this, importing it from a React Server Component fails. + outputOptions: { banner: "'use client';" }, plugins: [ stylexPlugin({ fileName: 'styles.css', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45745b15a08..00484a9beba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -876,6 +876,9 @@ importers: specifier: catalog:repo version: 2.8.1 devDependencies: + '@clerk/ui': + specifier: workspace:* + version: link:../ui crypto-es: specifier: ^2.1.0 version: 2.1.0 @@ -919,6 +922,9 @@ importers: '@clerk/shared': specifier: workspace:^ version: link:../shared + '@clerk/ui': + specifier: workspace:^ + version: link:../ui react: specifier: 18.3.1 version: 18.3.1 @@ -932,9 +938,6 @@ importers: '@clerk/localizations': specifier: workspace:* version: link:../localizations - '@clerk/ui': - specifier: workspace:* - version: link:../ui '@types/semver': specifier: ^7.7.1 version: 7.7.1 @@ -1195,6 +1198,9 @@ importers: packages/ui: dependencies: + '@clerk/headless': + specifier: workspace:^ + version: link:../headless '@clerk/localizations': specifier: workspace:^ version: link:../localizations @@ -1253,9 +1259,6 @@ importers: specifier: 18.3.1 version: 18.3.1(react@18.3.1) devDependencies: - '@clerk/headless': - specifier: workspace:^ - version: link:../headless '@floating-ui/react-dom': specifier: ^2.1.8 version: 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/scripts/copy-mosaic-styles.mjs b/scripts/copy-mosaic-styles.mjs new file mode 100644 index 00000000000..ca1fc61d330 --- /dev/null +++ b/scripts/copy-mosaic-styles.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +/** + * Copies `@clerk/ui`'s built Mosaic stylesheet into the calling package's dist, so an SDK can + * export it under its own name (`@clerk/nextjs/experimental/mosaic/styles.css`). + * + * Copied rather than re-exported through a path into `node_modules`: pnpm's layout gives no stable + * relative path from one package to another's files, so an export pointing there resolves only by + * luck of hoisting. + * + * Usage: node ../../scripts/copy-mosaic-styles.mjs + */ + +import { copyFileSync, mkdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; + +const dest = resolve(process.cwd(), process.argv[2]); +const source = createRequire(`${process.cwd()}/`).resolve('@clerk/ui/experimental/mosaic/styles.css'); + +mkdirSync(dirname(dest), { recursive: true }); +copyFileSync(source, dest); + +console.log(`✅ Copied the Mosaic stylesheet to ${process.argv[2]}`); From 0657c58fc3cc33c40905e4761c3bda350f661e9d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 5 Aug 2026 11:31:33 -0400 Subject: [PATCH 02/14] fix(ui): keep @clerk/headless out of the published mosaic export The experimental subpath pointed at `src/mosaic/styles`, which is the StyleX build barrel: it pulls every migrated component into the graph so one stylesheet can be extracted. Publishing it made all of those components, and the headless primitive types behind them, part of the API. Give the export its own narrow entry instead. `@clerk/headless` is private, so leaving it external published a dependency that 404s on install. Bundling it broke differently: the declaration builder cannot follow the re-export chains in its emitted `dist/*.d.ts`. Correct the `tsconfig.mosaic.json` paths so it resolves to source (primitives live under `src/primitives`, so the old wildcard missed menu, popover, dialog and tabs and silently fell back to `dist`). StyleX moves to devDependencies and gets bundled: it compiles away at build time, so consumers should not inherit our version. --- packages/ui/package.json | 4 +-- .../ui/src/mosaic/components/menu/index.ts | 2 -- .../ui/src/mosaic/components/menu/menu.tsx | 3 -- .../ui/src/mosaic/components/popover/index.ts | 1 - .../src/mosaic/components/popover/popover.tsx | 3 -- packages/ui/src/mosaic/index.ts | 7 ++++ packages/ui/src/mosaic/styles/index.ts | 3 -- packages/ui/tsconfig.mosaic.json | 4 ++- packages/ui/tsdown.mosaic.config.mts | 32 +++++++++++++------ pnpm-lock.yaml | 22 ++++++------- 10 files changed, 46 insertions(+), 35 deletions(-) create mode 100644 packages/ui/src/mosaic/index.ts diff --git a/packages/ui/package.json b/packages/ui/package.json index cc47970b430..d039f05f47a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -113,7 +113,6 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@clerk/headless": "workspace:^", "@clerk/localizations": "workspace:^", "@clerk/shared": "workspace:^", "@emotion/cache": "11.11.0", @@ -123,7 +122,6 @@ "@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", @@ -133,6 +131,7 @@ "qrcode.react": "4.2.0" }, "devDependencies": { + "@clerk/headless": "workspace:^", "@floating-ui/react-dom": "^2.1.8", "@rsdoctor/rspack-plugin": "^1.5.9", "@rspack/cli": "catalog:rspack", @@ -141,6 +140,7 @@ "@rspack/plugin-react-refresh": "catalog:rspack", "@stylexjs/eslint-plugin": "0.19.0", "@stylexjs/rollup-plugin": "0.19.0", + "@stylexjs/stylex": "0.19.0", "@stylexjs/unplugin": "0.19.0", "@svgr/rollup": "^8.1.0", "@svgr/webpack": "^6.5.1", diff --git a/packages/ui/src/mosaic/components/menu/index.ts b/packages/ui/src/mosaic/components/menu/index.ts index f2855739fdd..71f409f0ada 100644 --- a/packages/ui/src/mosaic/components/menu/index.ts +++ b/packages/ui/src/mosaic/components/menu/index.ts @@ -5,7 +5,5 @@ export type { MenuMediaProps, MenuMediaSize, MenuPopupProps, - MenuProps, - MenuSeparatorProps, MenuTriggerProps, } from './menu'; diff --git a/packages/ui/src/mosaic/components/menu/menu.tsx b/packages/ui/src/mosaic/components/menu/menu.tsx index b54b5af77d0..49872e69472 100644 --- a/packages/ui/src/mosaic/components/menu/menu.tsx +++ b/packages/ui/src/mosaic/components/menu/menu.tsx @@ -2,7 +2,6 @@ import type { MenuItemProps as PrimitiveMenuItemProps, MenuPopupProps as PrimitiveMenuPopupProps, MenuPortalProps, - MenuProps, MenuSeparatorProps, } from '@clerk/headless/menu'; import { Menu as Primitive } from '@clerk/headless/menu'; @@ -20,8 +19,6 @@ import { Icon } from '../icon'; import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; import * as slots from './menu.styles'; -export type { MenuProps, MenuSeparatorProps }; - export type MenuTriggerProps = MosaicComponentProps<'button'>; /** diff --git a/packages/ui/src/mosaic/components/popover/index.ts b/packages/ui/src/mosaic/components/popover/index.ts index 8ac0cb8c76b..7c72cb1ccbe 100644 --- a/packages/ui/src/mosaic/components/popover/index.ts +++ b/packages/ui/src/mosaic/components/popover/index.ts @@ -3,7 +3,6 @@ export type { PopoverCloseProps, PopoverDescriptionProps, PopoverPopupProps, - PopoverRootProps, PopoverSize, PopoverTitleProps, PopoverTriggerProps, diff --git a/packages/ui/src/mosaic/components/popover/popover.tsx b/packages/ui/src/mosaic/components/popover/popover.tsx index 0870f8c2310..0a6927fe858 100644 --- a/packages/ui/src/mosaic/components/popover/popover.tsx +++ b/packages/ui/src/mosaic/components/popover/popover.tsx @@ -1,4 +1,3 @@ -import type { PopoverProps as HeadlessPopoverProps } from '@clerk/headless/popover'; import { Popover as Primitive } from '@clerk/headless/popover'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -11,8 +10,6 @@ import { sizes, styles } from './popover.styles'; export type PopoverSize = 'sm' | 'md' | 'lg'; -export type PopoverRootProps = HeadlessPopoverProps; - /** * The headless parts type their props (and the `render` callback's argument) against * the raw tag props, which carry the non-standard HTML `color` attribute typed diff --git a/packages/ui/src/mosaic/index.ts b/packages/ui/src/mosaic/index.ts new file mode 100644 index 00000000000..f029a76256f --- /dev/null +++ b/packages/ui/src/mosaic/index.ts @@ -0,0 +1,7 @@ +// Public entry for `@clerk/ui/experimental/mosaic`. The side-effect import keeps every migrated +// component in the StyleX graph so the emitted `styles.css` stays complete, without making them API: +// `./styles` is the build barrel, and re-exporting it would publish the headless primitive types too. +import './styles'; + +export { UserButton } from './user-button/user-button'; +export type { UserButtonProps } from './user-button/user-button'; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index e04aad7e4c1..13f354ac60c 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -79,8 +79,6 @@ export type { MenuMediaProps, MenuMediaSize, MenuPopupProps, - MenuProps, - MenuSeparatorProps, MenuTriggerProps, } from '../components/menu'; export { scrollAreaRoot, scrollAreaVars, scrollAreaViewport } from '../components/scroll-area'; @@ -109,7 +107,6 @@ export type { PopoverCloseProps, PopoverDescriptionProps, PopoverPopupProps, - PopoverRootProps, PopoverSize, PopoverTitleProps, PopoverTriggerProps, diff --git a/packages/ui/tsconfig.mosaic.json b/packages/ui/tsconfig.mosaic.json index 05fde7579f6..516fc111be7 100644 --- a/packages/ui/tsconfig.mosaic.json +++ b/packages/ui/tsconfig.mosaic.json @@ -7,8 +7,10 @@ // declaration bundle. Its published `dist/*.d.ts` are re-export barrels that // rolldown-plugin-dts can't follow when inlining, so building types against source // (the monorepo default) lets Mosaic components import headless types directly. + // `utils` and `hooks` sit at the source root; every other subpath is a primitive. "@clerk/headless/utils": ["../headless/src/utils/index.ts"], - "@clerk/headless/*": ["../headless/src/*"], + "@clerk/headless/hooks": ["../headless/src/hooks/index.ts"], + "@clerk/headless/*": ["../headless/src/primitives/*"], // Preserve the base config's test-only aliases (extends replaces `paths` wholesale). "@/core/*": ["../clerk-js/src/core/*"], "@/*": ["./src/*"], diff --git a/packages/ui/tsdown.mosaic.config.mts b/packages/ui/tsdown.mosaic.config.mts index 2bf2ca6a723..b28b39d612f 100644 --- a/packages/ui/tsdown.mosaic.config.mts +++ b/packages/ui/tsdown.mosaic.config.mts @@ -2,17 +2,20 @@ import stylexPlugin from '@stylexjs/rollup-plugin'; import { defineConfig } from 'tsdown'; import { mosaicLightningCssTargets } from './stylex-lightningcss.config.mjs'; -// Isolated Mosaic build: compiles ONLY the StyleX barrel (`src/mosaic/styles`) -// with the StyleX rollup plugin, emitting transformed ESM + a single static -// `styles.css` that consumers import. Kept separate from the main tsdown build so -// the Emotion-based code is untouched and this entry stays Emotion-free. +// Isolated Mosaic build: compiles `src/mosaic` with the StyleX rollup plugin, emitting transformed +// ESM + a single static `styles.css` that consumers import. Kept separate from the main tsdown build +// so the Emotion-based code is untouched and this entry stays Emotion-free. +// +// The entry is the narrow public surface, not the `src/mosaic/styles` barrel: the barrel exists to +// pull every migrated component into the StyleX graph, and pointing the published export at it would +// make all of them (and the headless primitive types behind them) API. // // `useCSSLayers` wraps StyleX's atomic rules in `@layer priorityN` for correct // intra-StyleX precedence; consumers import the sheet into a layer they control // (`@import '@clerk/ui/experimental/mosaic/styles.css' layer(components)`), under which those nest // cleanly, and override from a later layer. export default defineConfig({ - entry: ['./src/mosaic/styles/index.ts'], + entry: ['./src/mosaic/index.ts'], outDir: './dist-mosaic', format: ['esm'], dts: true, @@ -22,10 +25,21 @@ export default defineConfig({ minify: false, // Use the standard React JSX runtime, not Emotion's — the Mosaic build must be Emotion-free. tsconfig: './tsconfig.mosaic.json', - // `@clerk/headless` stays external here (the main build inlines it): this entry exists to - // extract `styles.css`, and only that file is exported from the package — so there is nothing - // to gain from pulling the primitives and their deps into a bundle nobody imports. - external: ['react', 'react-dom', '@stylexjs/stylex', /^@clerk\/headless/], + // tsdown externalizes everything in `dependencies` by default, which is what we want for + // `@clerk/shared`: it carries the Clerk context, so the host's copy has to be the one we read. + // The two below have to override that default. + // + // `@clerk/headless` is a private workspace package. Left external, `@clerk/ui` publishes with a + // dependency that does not exist on npm, and installing it 404s. `tsconfig.mosaic.json` already + // resolves it to source, so this is the backstop: if a subpath ever escapes those `paths`, the + // build fails loudly here instead of silently externalizing an unpublishable package. + // + // StyleX is compiled away at build time; only the tiny `props` merger survives. Bundling it keeps + // it out of consumer trees entirely, so nobody inherits our StyleX version or has to have it. + deps: { + neverBundle: ['react', 'react-dom'], + alwaysBundle: [/^@clerk\/headless/, '@stylexjs/stylex'], + }, // The bundle collapses every module into one, so the per-file `'use client'` directives are lost. // Everything here is interactive and hook-driven, so the entry is a client boundary in whole — // without this, importing it from a React Server Component fails. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00484a9beba..9758e2ada79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -813,10 +813,10 @@ importers: version: 4.5.4(@types/node@25.6.0)(rollup@4.61.0)(typescript@6.0.3)(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) vitest: specifier: 4.1.6 - version: 4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + version: 4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(vitest@4.1.5))(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) vitest-axe: specifier: ^0.1.0 - version: 0.1.0(vitest@4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))) + version: 0.1.0(vitest@4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(vitest@4.1.5))(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))) packages/hono: dependencies: @@ -1198,9 +1198,6 @@ importers: packages/ui: dependencies: - '@clerk/headless': - specifier: workspace:^ - version: link:../headless '@clerk/localizations': specifier: workspace:^ version: link:../localizations @@ -1228,9 +1225,6 @@ importers: '@solana/wallet-standard': specifier: catalog:module-manager version: 1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10))(bs58@6.0.0)(react@18.3.1) - '@stylexjs/stylex': - specifier: 0.19.0 - version: 0.19.0 '@swc/helpers': specifier: catalog:repo version: 0.5.21 @@ -1259,6 +1253,9 @@ importers: specifier: 18.3.1 version: 18.3.1(react@18.3.1) devDependencies: + '@clerk/headless': + specifier: workspace:^ + version: link:../headless '@floating-ui/react-dom': specifier: ^2.1.8 version: 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1283,6 +1280,9 @@ importers: '@stylexjs/rollup-plugin': specifier: 0.19.0 version: 0.19.0 + '@stylexjs/stylex': + specifier: 0.19.0 + version: 0.19.0 '@stylexjs/unplugin': specifier: 0.19.0 version: 0.19.0(unplugin@2.3.11) @@ -32104,7 +32104,7 @@ snapshots: optionalDependencies: vite: 7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) - vitest-axe@0.1.0(vitest@4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))): + vitest-axe@0.1.0(vitest@4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(vitest@4.1.5))(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))): dependencies: aria-query: 5.3.2 axe-core: 4.11.4 @@ -32112,7 +32112,7 @@ snapshots: dom-accessibility-api: 0.5.16 lodash-es: 4.18.1 redent: 3.0.0 - vitest: 4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + vitest: 4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(vitest@4.1.5))(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) vitest-chrome@0.1.0: dependencies: @@ -32195,7 +32195,7 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): + vitest@4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(vitest@4.1.5))(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.6 '@vitest/mocker': 4.1.6(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) From a2b69b1eb24d77d5700138ec49f10d528ad6ec19 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 09:57:39 -0400 Subject: [PATCH 03/14] chore(repo): dedupe the lockfile --- pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9758e2ada79..47bbd1d01d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -813,10 +813,10 @@ importers: version: 4.5.4(@types/node@25.6.0)(rollup@4.61.0)(typescript@6.0.3)(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) vitest: specifier: 4.1.6 - version: 4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(vitest@4.1.5))(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + version: 4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) vitest-axe: specifier: ^0.1.0 - version: 0.1.0(vitest@4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(vitest@4.1.5))(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))) + version: 0.1.0(vitest@4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))) packages/hono: dependencies: @@ -32104,7 +32104,7 @@ snapshots: optionalDependencies: vite: 7.3.5(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) - vitest-axe@0.1.0(vitest@4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(vitest@4.1.5))(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))): + vitest-axe@0.1.0(vitest@4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0))): dependencies: aria-query: 5.3.2 axe-core: 4.11.4 @@ -32112,7 +32112,7 @@ snapshots: dom-accessibility-api: 0.5.16 lodash-es: 4.18.1 redent: 3.0.0 - vitest: 4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(vitest@4.1.5))(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) + vitest: 4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) vitest-chrome@0.1.0: dependencies: @@ -32195,7 +32195,7 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(vitest@4.1.5))(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): + vitest@4.1.6(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.6 '@vitest/mocker': 4.1.6(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) From 8d0b413a01d6b3f55b55784529d2fddd9340922f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 15:53:21 -0400 Subject: [PATCH 04/14] feat(ui): export UserButton and its props from the mosaic subpath Keeps the experimental surface to the component and the props it takes; everything behind it stays internal. --- packages/ui/src/mosaic/user-button/user-button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index 4abdcfc67cc..02297cdc2a0 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -50,7 +50,7 @@ export type UserButtonProps = UserButtonModelOptions & * * @example * ```tsx - * import { UserButton } from '@clerk/ui/mosaic'; + * import { UserButton } from '@clerk/nextjs/experimental/mosaic'; * * * ``` From e4627687ce6bec6314890cc34280db3dcdcf4ac4 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 17:15:43 -0400 Subject: [PATCH 05/14] test(ui): type-test the public UserButton props Locks the props a consumer writes against `@clerk/nextjs/experimental/mosaic`: what the switcher modes are called, that a routing URL cannot ask for a modal, and that a menu row or profile page has to be one of its two shapes. --- .../mosaic/user-button/user-button.test-d.ts | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 packages/ui/src/mosaic/user-button/user-button.test-d.ts diff --git a/packages/ui/src/mosaic/user-button/user-button.test-d.ts b/packages/ui/src/mosaic/user-button/user-button.test-d.ts new file mode 100644 index 00000000000..e1fe0cb19e6 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/user-button.test-d.ts @@ -0,0 +1,177 @@ +import type { OrganizationResource, UserResource } from '@clerk/shared/types'; +import { describe, expectTypeOf, test } from 'vitest'; + +import type { UserButtonProps } from '../index'; + +// The published surface of `@clerk/nextjs/experimental/mosaic`, imported the way a consumer gets it. +// Object literals reach `accept` the same way JSX attributes reach the component, excess-property +// checking and all, so a rejection here is a rejection a consumer would see. +// +// Rejections stay on one line: `@ts-expect-error` only covers the line that follows it, and a +// literal spread over several lines can report its error on any of them. +const accept = (props: UserButtonProps): UserButtonProps => props; + +// ─── The surface as a whole ────────────────────────────────────────────────── + +describe('UserButtonProps — nothing is required', () => { + test('the button takes no props at all', () => { + accept({}); + }); + + test('a misspelled prop is caught rather than silently ignored', () => { + // @ts-expect-error — `userProfileUrls` is not a prop + accept({ userProfileUrls: '/account' }); + }); +}); + +// ─── mode / modePriority ───────────────────────────────────────────────────── + +describe('mode and modePriority — the two vocabularies stay apart', () => { + test('mode is the three switcher shapes', () => { + expectTypeOf().toEqualTypeOf<'combined' | 'orgs' | 'user' | undefined>(); + }); + + test('modePriority names the organization in full, unlike mode', () => { + expectTypeOf().toEqualTypeOf<'organizations' | 'user' | undefined>(); + }); + + test('every mode is accepted', () => { + accept({ mode: 'combined' }); + accept({ mode: 'orgs' }); + accept({ mode: 'user' }); + }); + + test('a mode outside the union is rejected', () => { + // @ts-expect-error — 'organizations' is modePriority's word, not mode's + accept({ mode: 'organizations' }); + }); + + test("modePriority does not take mode's abbreviation", () => { + // @ts-expect-error — 'orgs' is mode's word, not modePriority's + accept({ modePriority: 'orgs' }); + }); +}); + +// ─── Routing: url and mode cannot contradict each other ────────────────────── + +describe('profile routing — a URL is the whole opt-in to navigation', () => { + test('a URL alone routes; naming navigation alongside it is allowed', () => { + accept({ userProfileUrl: '/account' }); + accept({ userProfileUrl: '/account', userProfileMode: 'navigation' }); + accept({ organizationProfileUrl: '/org', organizationProfileMode: 'navigation' }); + accept({ createOrganizationUrl: '/org/new', createOrganizationMode: 'navigation' }); + }); + + test('modal is the default, and stands on its own', () => { + accept({ userProfileMode: 'modal' }); + accept({ organizationProfileMode: 'modal' }); + accept({ createOrganizationMode: 'modal' }); + }); + + test('a URL cannot ask for a modal', () => { + // @ts-expect-error — a URL means navigation; 'modal' contradicts it + accept({ userProfileUrl: '/account', userProfileMode: 'modal' }); + // @ts-expect-error — same contradiction on the organization profile + accept({ organizationProfileUrl: '/org', organizationProfileMode: 'modal' }); + // @ts-expect-error — same contradiction on create-organization + accept({ createOrganizationUrl: '/org/new', createOrganizationMode: 'modal' }); + }); + + test('the three surfaces are configured apart — routing one leaves the others modal', () => { + accept({ userProfileUrl: '/account', organizationProfileMode: 'modal' }); + }); +}); + +describe('after-select URLs — each builder gets the entity it resolves against', () => { + test('a path template is accepted', () => { + accept({ afterSelectOrganizationUrl: '/orgs/:slug', afterSelectPersonalUrl: '/me' }); + }); + + test('the organization builder receives an organization', () => { + accept({ + afterSelectOrganizationUrl: organization => { + expectTypeOf(organization).toEqualTypeOf(); + return `/orgs/${organization.id}`; + }, + }); + }); + + test('the personal builder receives the user, not an organization', () => { + accept({ + afterSelectPersonalUrl: user => { + expectTypeOf(user).toEqualTypeOf(); + return `/users/${user.id}`; + }, + }); + }); + + test('a builder must return a string', () => { + // @ts-expect-error — the URL is what gets navigated to; there is nothing to do with a number + accept({ afterSelectOrganizationUrl: () => 42 }); + }); +}); + +// ─── Custom menu items ─────────────────────────────────────────────────────── + +describe('customMenuItems — a row either acts or leaves', () => { + test('an action row', () => { + accept({ customMenuItems: [{ id: 'support', label: 'Contact support', onClick: () => {} }] }); + }); + + test('a link row', () => { + accept({ customMenuItems: [{ id: 'docs', label: 'Documentation', href: 'https://example.com' }] }); + }); + + test('a row cannot do both', () => { + // @ts-expect-error — `href` and `onClick` are mutually exclusive + accept({ customMenuItems: [{ id: 'x', label: 'X', href: '/x', onClick: () => {} }] }); + }); + + test('a row must do one', () => { + // @ts-expect-error — a row with neither `href` nor `onClick` does nothing + accept({ customMenuItems: [{ id: 'x', label: 'X' }] }); + }); + + test('menuItemOrder takes built-in ids and the app’s own, side by side', () => { + accept({ menuItemOrder: ['docs', 'createOrganization', 'addAccount', 'signOutAll'] }); + }); +}); + +// ─── The profile the button opens ──────────────────────────────────────────── + +describe('userProfileProps — a navigation entry either has content or goes somewhere', () => { + test('a page brings its own content', () => { + accept({ userProfileProps: { customPages: [{ label: 'Usage', path: 'usage', content: null }] } }); + }); + + test('a link goes somewhere else', () => { + accept({ userProfileProps: { customPages: [{ label: 'Docs', path: 'docs', href: 'https://example.com' }] } }); + }); + + test('an entry cannot be both', () => { + // @ts-expect-error — `content` and `href` are mutually exclusive + accept({ userProfileProps: { customPages: [{ label: 'X', path: 'x', content: null, href: '/x' }] } }); + }); + + test('an entry needs a path to be ordered by', () => { + // @ts-expect-error — `path` identifies the entry, so it is required either way + accept({ userProfileProps: { customPages: [{ label: 'X', content: null }] } }); + }); + + test('pageOrder takes built-in page ids and custom paths, side by side', () => { + accept({ userProfileProps: { pageOrder: ['account', 'usage', 'security', 'billing', 'apiKeys'] } }); + }); +}); + +// ─── Trigger ───────────────────────────────────────────────────────────────── + +describe('trigger flags', () => { + test('both are booleans', () => { + accept({ renderTriggerLabel: false, renderPlanBadge: false }); + }); + + test('a truthy value of another type is rejected', () => { + // @ts-expect-error — `renderTriggerLabel` is a boolean, not a label + accept({ renderTriggerLabel: 'Acme' }); + }); +}); From f4e960ec07915e4140f885e937b262264d71e2fb Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 18:56:39 -0400 Subject: [PATCH 06/14] fix(ui): bundle Floating UI into the mosaic entry Floating UI arrives through the bundled @clerk/headless primitives, so leaving it external made it the one install this entry still demanded. SDKs inline this bundle so consumers need nothing beyond React and @clerk/shared. --- packages/ui/tsdown.mosaic.config.mts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ui/tsdown.mosaic.config.mts b/packages/ui/tsdown.mosaic.config.mts index b28b39d612f..f6c29047e83 100644 --- a/packages/ui/tsdown.mosaic.config.mts +++ b/packages/ui/tsdown.mosaic.config.mts @@ -36,9 +36,14 @@ export default defineConfig({ // // StyleX is compiled away at build time; only the tiny `props` merger survives. Bundling it keeps // it out of consumer trees entirely, so nobody inherits our StyleX version or has to have it. + // + // Floating UI arrives through the bundled `@clerk/headless` primitives, so leaving it external + // would make it the one install this entry still demands, defeating the point: SDKs inline this + // bundle so consumers need nothing beyond React and `@clerk/shared`. Its contexts are per-tree, + // not global, so a second copy alongside the Emotion UI's is inert. deps: { neverBundle: ['react', 'react-dom'], - alwaysBundle: [/^@clerk\/headless/, '@stylexjs/stylex'], + alwaysBundle: [/^@clerk\/headless/, '@stylexjs/stylex', /^@floating-ui\//], }, // The bundle collapses every module into one, so the per-file `'use client'` directives are lost. // Everything here is interactive and hook-driven, so the entry is a client boundary in whole — From b13972bf680b613101df56496d6381131b226a8a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 18:56:43 -0400 Subject: [PATCH 07/14] chore(ui): watch the size of the mosaic bundle --- packages/ui/bundlewatch.config.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index 4206be9da18..da3ee569b2a 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -33,6 +33,8 @@ { "path": "./dist/op-plans-page*.js", "maxSize": "3KB" }, { "path": "./dist/statement-page*.js", "maxSize": "5KB" }, { "path": "./dist/payment-attempt-page*.js", "maxSize": "4KB" }, - { "path": "./dist/web3-solana-wallet-buttons*.js", "maxSize": "85KB" } + { "path": "./dist/web3-solana-wallet-buttons*.js", "maxSize": "85KB" }, + { "path": "./dist-mosaic/styles.css", "maxSize": "8KB" }, + { "path": "./dist-mosaic/index.js", "maxSize": "88KB" } ] } From 8bd732b84c94baba4d4ff1b396c8386e2ba94d83 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 18:56:45 -0400 Subject: [PATCH 08/14] fix(react): inline the mosaic entry so @clerk/ui stays out of consumer trees Left external, the re-export resolved from the consumer's tree at runtime, which made @clerk/ui a dependency and installed its whole graph (Emotion, the Solana wallet adapters, ...) for every consumer, Mosaic or not. --- packages/react/package.json | 2 +- packages/react/src/experimental/mosaic.ts | 2 ++ packages/react/tsdown.config.mts | 7 ++++++- pnpm-lock.yaml | 6 +++--- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/react/package.json b/packages/react/package.json index 444663ee186..2d7f76b192f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -113,11 +113,11 @@ }, "dependencies": { "@clerk/shared": "workspace:^", - "@clerk/ui": "workspace:^", "tslib": "catalog:repo" }, "devDependencies": { "@clerk/localizations": "workspace:*", + "@clerk/ui": "workspace:*", "@types/semver": "^7.7.1", "semver": "^7.8.5", "yaml": "^2.9.0" diff --git a/packages/react/src/experimental/mosaic.ts b/packages/react/src/experimental/mosaic.ts index 6ffe79f4533..1c841f4012f 100644 --- a/packages/react/src/experimental/mosaic.ts +++ b/packages/react/src/experimental/mosaic.ts @@ -1,3 +1,5 @@ +'use client'; + /** * Mosaic components mounted directly in the host app's tree, rather than through clerk-js. They * read Clerk via `@clerk/shared/react` hooks, so a `ClerkProvider` above them is all they need. diff --git a/packages/react/tsdown.config.mts b/packages/react/tsdown.config.mts index 7f101bb8730..de1302b7242 100644 --- a/packages/react/tsdown.config.mts +++ b/packages/react/tsdown.config.mts @@ -77,7 +77,12 @@ export default defineConfig((overrideOptions: Options) => { // Bundle @clerk/ui/register inline at build time so consumers don't need // @clerk/ui as a dependency. The registration code sets up globalThis.__clerkSharedModules // to enable @clerk/ui's shared variant to use the host app's React. - noExternal: ['@clerk/ui/register'], + // + // The Mosaic entry is inlined for the same reason: left external, the re-export resolves from + // the consumer's tree at runtime, which makes @clerk/ui a dependency and installs its whole + // graph (Emotion, the Solana wallet adapters, ...) for every consumer, Mosaic or not. Its build + // already bundles everything except React and @clerk/shared, both of which we ship anyway. + noExternal: ['@clerk/ui/register', '@clerk/ui/experimental/mosaic'], define: { PACKAGE_NAME: `"${pkgJson.name}"`, PACKAGE_VERSION: `"${pkgJson.version}"`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47bbd1d01d6..79e3ee4c45d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -922,9 +922,6 @@ importers: '@clerk/shared': specifier: workspace:^ version: link:../shared - '@clerk/ui': - specifier: workspace:^ - version: link:../ui react: specifier: 18.3.1 version: 18.3.1 @@ -938,6 +935,9 @@ importers: '@clerk/localizations': specifier: workspace:* version: link:../localizations + '@clerk/ui': + specifier: workspace:* + version: link:../ui '@types/semver': specifier: ^7.7.1 version: 7.7.1 From 6e9b81ca3d9263681b258480101cf91de4d35528 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 7 Aug 2026 10:26:41 -0400 Subject: [PATCH 09/14] refactor(ui): follow the organization mode rename in the type tests The two tests guarding `mode` and `modePriority` against each other's wording go away with the divergence they guarded. --- .../mosaic/user-button/user-button.test-d.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/user-button.test-d.ts b/packages/ui/src/mosaic/user-button/user-button.test-d.ts index e1fe0cb19e6..fbabcf94fce 100644 --- a/packages/ui/src/mosaic/user-button/user-button.test-d.ts +++ b/packages/ui/src/mosaic/user-button/user-button.test-d.ts @@ -26,29 +26,29 @@ describe('UserButtonProps — nothing is required', () => { // ─── mode / modePriority ───────────────────────────────────────────────────── -describe('mode and modePriority — the two vocabularies stay apart', () => { +describe('mode and modePriority', () => { test('mode is the three switcher shapes', () => { - expectTypeOf().toEqualTypeOf<'combined' | 'orgs' | 'user' | undefined>(); + expectTypeOf().toEqualTypeOf<'combined' | 'organization' | 'user' | undefined>(); }); - test('modePriority names the organization in full, unlike mode', () => { - expectTypeOf().toEqualTypeOf<'organizations' | 'user' | undefined>(); + test('modePriority is the two a combined surface chooses between', () => { + expectTypeOf().toEqualTypeOf<'organization' | 'user' | undefined>(); }); test('every mode is accepted', () => { accept({ mode: 'combined' }); - accept({ mode: 'orgs' }); + accept({ mode: 'organization' }); accept({ mode: 'user' }); }); test('a mode outside the union is rejected', () => { - // @ts-expect-error — 'organizations' is modePriority's word, not mode's - accept({ mode: 'organizations' }); + // @ts-expect-error - 'orgs' is not the word for it + accept({ mode: 'orgs' }); }); - test("modePriority does not take mode's abbreviation", () => { - // @ts-expect-error — 'orgs' is mode's word, not modePriority's - accept({ modePriority: 'orgs' }); + test('combined is not something modePriority can lead with', () => { + // @ts-expect-error - 'combined' is a mode, not a priority + accept({ modePriority: 'combined' }); }); }); From 7fa370fabf7174bb4e390e5f99f2cf03dcd7a01b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 7 Aug 2026 15:30:30 -0400 Subject: [PATCH 10/14] test(ui): type the renderPlanBadge renderer Pins the return shape: `{ name, slug? }`, `null` to decline, no arguments in. --- .../mosaic/user-button/user-button.test-d.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/ui/src/mosaic/user-button/user-button.test-d.ts b/packages/ui/src/mosaic/user-button/user-button.test-d.ts index fbabcf94fce..3e847839957 100644 --- a/packages/ui/src/mosaic/user-button/user-button.test-d.ts +++ b/packages/ui/src/mosaic/user-button/user-button.test-d.ts @@ -175,3 +175,34 @@ describe('trigger flags', () => { accept({ renderTriggerLabel: 'Acme' }); }); }); + +describe('renderPlanBadge as a renderer', () => { + test('names the plan itself, synchronously or not', () => { + accept({ renderPlanBadge: () => ({ name: 'Enterprise' }) }); + accept({ renderPlanBadge: () => ({ name: 'Enterprise', slug: 'plan_enterprise' }) }); + accept({ renderPlanBadge: async () => ({ name: 'Enterprise', slug: 'plan_enterprise' }) }); + }); + + test('declining a badge is a null return, not undefined', () => { + accept({ renderPlanBadge: () => null }); + // @ts-expect-error — return `null` to draw no badge; `undefined` reads as a forgotten return + accept({ renderPlanBadge: () => undefined }); + }); + + test('a bare label is not a badge', () => { + // @ts-expect-error — the renderer returns `{ name }`, not the name itself + accept({ renderPlanBadge: () => 'Enterprise' }); + }); + + test('name is required, and slug is a string', () => { + // @ts-expect-error — `name` is what the badge renders, so it is required + accept({ renderPlanBadge: () => ({ slug: 'plan_enterprise' }) }); + // @ts-expect-error — `slug` identifies the plan as a string + accept({ renderPlanBadge: () => ({ name: 'Enterprise', slug: 42 }) }); + }); + + test('the renderer takes no arguments', () => { + // @ts-expect-error — nothing is passed in; read what you need from your own data + accept({ renderPlanBadge: (organizationId: string) => ({ name: organizationId }) }); + }); +}); From 8a41a8bc1ee543a980e4dce410434e31191f5c59 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 7 Aug 2026 16:17:48 -0400 Subject: [PATCH 11/14] refactor(ui): follow the renderTriggerBadge rename through the type tests --- .../mosaic/user-button/user-button.test-d.ts | 35 ++----------------- 1 file changed, 3 insertions(+), 32 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/user-button.test-d.ts b/packages/ui/src/mosaic/user-button/user-button.test-d.ts index 3e847839957..af5bb59346d 100644 --- a/packages/ui/src/mosaic/user-button/user-button.test-d.ts +++ b/packages/ui/src/mosaic/user-button/user-button.test-d.ts @@ -167,42 +167,13 @@ describe('userProfileProps — a navigation entry either has content or goes som describe('trigger flags', () => { test('both are booleans', () => { - accept({ renderTriggerLabel: false, renderPlanBadge: false }); + accept({ renderTriggerLabel: false, renderTriggerBadge: false }); }); test('a truthy value of another type is rejected', () => { // @ts-expect-error — `renderTriggerLabel` is a boolean, not a label accept({ renderTriggerLabel: 'Acme' }); - }); -}); - -describe('renderPlanBadge as a renderer', () => { - test('names the plan itself, synchronously or not', () => { - accept({ renderPlanBadge: () => ({ name: 'Enterprise' }) }); - accept({ renderPlanBadge: () => ({ name: 'Enterprise', slug: 'plan_enterprise' }) }); - accept({ renderPlanBadge: async () => ({ name: 'Enterprise', slug: 'plan_enterprise' }) }); - }); - - test('declining a badge is a null return, not undefined', () => { - accept({ renderPlanBadge: () => null }); - // @ts-expect-error — return `null` to draw no badge; `undefined` reads as a forgotten return - accept({ renderPlanBadge: () => undefined }); - }); - - test('a bare label is not a badge', () => { - // @ts-expect-error — the renderer returns `{ name }`, not the name itself - accept({ renderPlanBadge: () => 'Enterprise' }); - }); - - test('name is required, and slug is a string', () => { - // @ts-expect-error — `name` is what the badge renders, so it is required - accept({ renderPlanBadge: () => ({ slug: 'plan_enterprise' }) }); - // @ts-expect-error — `slug` identifies the plan as a string - accept({ renderPlanBadge: () => ({ name: 'Enterprise', slug: 42 }) }); - }); - - test('the renderer takes no arguments', () => { - // @ts-expect-error — nothing is passed in; read what you need from your own data - accept({ renderPlanBadge: (organizationId: string) => ({ name: organizationId }) }); + // @ts-expect-error — the badge names the active organization's plan; it is not yours to supply + accept({ renderTriggerBadge: () => ({ name: 'Enterprise' }) }); }); }); From d8ea4bcb6eb14b5e2aa3d48ab9266d01fd23966f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 10 Aug 2026 11:48:13 -0400 Subject: [PATCH 12/14] test(ui): move the user button type tests into __tests__ --- .../mosaic/user-button/{ => __tests__}/user-button.test-d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename packages/ui/src/mosaic/user-button/{ => __tests__}/user-button.test-d.ts (99%) diff --git a/packages/ui/src/mosaic/user-button/user-button.test-d.ts b/packages/ui/src/mosaic/user-button/__tests__/user-button.test-d.ts similarity index 99% rename from packages/ui/src/mosaic/user-button/user-button.test-d.ts rename to packages/ui/src/mosaic/user-button/__tests__/user-button.test-d.ts index af5bb59346d..af7d6c0b788 100644 --- a/packages/ui/src/mosaic/user-button/user-button.test-d.ts +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.test-d.ts @@ -1,7 +1,7 @@ import type { OrganizationResource, UserResource } from '@clerk/shared/types'; import { describe, expectTypeOf, test } from 'vitest'; -import type { UserButtonProps } from '../index'; +import type { UserButtonProps } from '../../index'; // The published surface of `@clerk/nextjs/experimental/mosaic`, imported the way a consumer gets it. // Object literals reach `accept` the same way JSX attributes reach the component, excess-property From 4486d33270409316ede809783761dcc7d4d35d2e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 28 Aug 2026 09:31:04 -0400 Subject: [PATCH 13/14] chore(ui): raise the mosaic bundle size limits --- packages/ui/bundlewatch.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index da3ee569b2a..5714c5fc246 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -34,7 +34,7 @@ { "path": "./dist/statement-page*.js", "maxSize": "5KB" }, { "path": "./dist/payment-attempt-page*.js", "maxSize": "4KB" }, { "path": "./dist/web3-solana-wallet-buttons*.js", "maxSize": "85KB" }, - { "path": "./dist-mosaic/styles.css", "maxSize": "8KB" }, - { "path": "./dist-mosaic/index.js", "maxSize": "88KB" } + { "path": "./dist-mosaic/styles.css", "maxSize": "10KB" }, + { "path": "./dist-mosaic/index.js", "maxSize": "103KB" } ] } From 9c351002c8a39317d79d1b83aa262f2591bf88ad Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 28 Aug 2026 11:03:45 -0400 Subject: [PATCH 14/14] fix(swingset): scan mosaic source for stylex css extraction The postcss plugin's package auto-discovery only recognizes a dependency as a StyleX package when its manifest lists `@stylexjs/stylex` under dependencies, peerDependencies, or optionalDependencies. Moving it to `@clerk/ui`'s devDependencies silently dropped `packages/ui/src/mosaic` from extraction, so no `:root { --cl-* }` token defaults were emitted and the preview deploy rendered unstyled. Name the source in `include` instead of relying on discovery. --- packages/swingset/postcss.config.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/swingset/postcss.config.mjs b/packages/swingset/postcss.config.mjs index 6a9d09bc734..ad7437a8eb6 100644 --- a/packages/swingset/postcss.config.mjs +++ b/packages/swingset/postcss.config.mjs @@ -21,8 +21,12 @@ const isDev = process.env.NODE_ENV !== 'production'; // `runtimeInjection` (see `next.config.mjs`) injects the *fresh* atom at runtime under a new // content hash, which HMR tracks. The stale extracted atom is dead CSS; the `:root` token // defaults never change mid-session, so they stay correct. +// +// `include` names the Mosaic source explicitly: the plugin's auto-discovery ignores +// `devDependencies`, which is where `@clerk/ui` keeps `@stylexjs/stylex`. const stylexExtraction = { '@stylexjs/postcss-plugin': { + include: ['src/**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}', `${uiRoot}/src/mosaic/**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}`], useCSSLayers: true, babelConfig: { babelrc: false,