From 9aae101d9261130d81dc3df58ef343549dff38b6 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 4 Aug 2026 09:54:20 -0400 Subject: [PATCH 01/13] 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 | 2 ++ 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, 154 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 bc818e39633..ef4bb20e04f 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.21" }, 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 d593ddad99b..2b0192c3128 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 8b0926f0534..281503430fe 100644 --- a/packages/swingset/src/stories/menu.component.mdx +++ b/packages/swingset/src/stories/menu.component.mdx @@ -216,10 +216,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 de466ce8971..576f663b2ff 100644 --- a/packages/swingset/src/stories/popover.component.mdx +++ b/packages/swingset/src/stories/popover.component.mdx @@ -183,10 +183,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 c616c23024f..1b440cbb7ad 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 6182d413050..11c7319a2fc 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -5,6 +5,8 @@ // as components migrate. export type { MosaicComponentProps, MosaicElementProps } from '../props'; +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 b3ecba5aa74..a607bff4819 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -884,6 +884,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 @@ -927,6 +930,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 @@ -940,9 +946,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 @@ -1203,6 +1206,9 @@ importers: packages/ui: dependencies: + '@clerk/headless': + specifier: workspace:^ + version: link:../headless '@clerk/localizations': specifier: workspace:^ version: link:../localizations @@ -1261,9 +1267,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.9(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 bcdb6a67565567bc8e6f342b258e10e73b3d9796 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 5 Aug 2026 11:31:33 -0400 Subject: [PATCH 02/13] 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 | 12 +++---- 10 files changed, 41 insertions(+), 30 deletions(-) create mode 100644 packages/ui/src/mosaic/index.ts diff --git a/packages/ui/package.json b/packages/ui/package.json index 1b440cbb7ad..7122bd0c60e 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 d2995fbf544..6be555cb96f 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' | 'anchor'; -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 11c7319a2fc..fbc7b6c59ef 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'; @@ -111,7 +109,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 a607bff4819..1309c6cfa16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1206,9 +1206,6 @@ importers: packages/ui: dependencies: - '@clerk/headless': - specifier: workspace:^ - version: link:../headless '@clerk/localizations': specifier: workspace:^ version: link:../localizations @@ -1236,9 +1233,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 @@ -1267,6 +1261,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.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1291,6 +1288,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) From 05d2c8f9c571bfbb07abba4b5a6b1e5f9f175845 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 15:53:21 -0400 Subject: [PATCH 03/13] 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 9e878438bf2..d604b750125 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 c5c232d19351fcb747e6b93035bd2d020ed0da54 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 17:15:43 -0400 Subject: [PATCH 04/13] 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: '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/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: '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/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: '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/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 193044fb703c4c6e13facc423686260db6ab777c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 18:56:39 -0400 Subject: [PATCH 05/13] 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 804017491494844b0c0c51a6ab4dc25fa730d0c8 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 18:56:43 -0400 Subject: [PATCH 06/13] 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 e1e6d6e6fc6..60ad5b47a62 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -34,6 +34,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 6fa601a0f74c457645cc9f397ba6b850d7a5865f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 18:56:45 -0400 Subject: [PATCH 07/13] 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 2b0192c3128..a68069b77b3 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 1309c6cfa16..f2aad6d16e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -930,9 +930,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 @@ -946,6 +943,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 c9672a98ff940281e44fcaf8c14c891ff9a77f22 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 7 Aug 2026 10:26:41 -0400 Subject: [PATCH 08/13] 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 b67e0793c3a92f6c908b46e6d7ae26380f84521b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 7 Aug 2026 15:30:30 -0400 Subject: [PATCH 09/13] 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 6a40d980d8f724db3d8e10c78ec98975ef658c48 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 7 Aug 2026 16:17:48 -0400 Subject: [PATCH 10/13] 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 f0a28951d6aa282f5f6212daca5ad776d97fbb7f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 10 Aug 2026 11:48:13 -0400 Subject: [PATCH 11/13] 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 a9d0f11bf4f3d3e5baa3231ebe81806bf6173f25 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 28 Aug 2026 09:31:04 -0400 Subject: [PATCH 12/13] 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 60ad5b47a62..12c2869651b 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -35,7 +35,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 48d3ebec332a093c16c2d5ecee56782900675024 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 28 Aug 2026 11:03:45 -0400 Subject: [PATCH 13/13] 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,