From 1da991e8bbbfb083b4e05c0dfe2790d3b2afa084 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 4 Aug 2026 09:54:20 -0400 Subject: [PATCH 1/9] 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 | 3 +++ 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, 155 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 38f86642269..916beecd46f 100644 --- a/.claude/skills/mosaic/references/stylex.md +++ b/.claude/skills/mosaic/references/stylex.md @@ -621,8 +621,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 700a78d22b0..20690715cf1 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 210204c6b49..b15787754f8 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 b1a749eaeea..33047d7ef42 100644 --- a/packages/swingset/src/stories/menu.component.mdx +++ b/packages/swingset/src/stories/menu.component.mdx @@ -120,10 +120,10 @@ const [open, setOpen] = useState(false); Unlike the slot-recipe components, 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 e1b8e2aecad..a2145f370e0 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. Unlike the slot-recipe components, 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 cd7d5172288..810177a8dd7 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 9f5d0813832..018c91cf01f 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -6,6 +6,9 @@ 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 } 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 d071c614880..f1bbb5dce1f 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 @@ -1198,6 +1201,9 @@ importers: packages/ui: dependencies: + '@clerk/headless': + specifier: workspace:^ + version: link:../headless '@clerk/localizations': specifier: workspace:^ version: link:../localizations @@ -1256,9 +1262,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 08a9d3c2e88b5e35f46365e8ae73b2e1b418aa0b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 5 Aug 2026 11:31:33 -0400 Subject: [PATCH 2/9] 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 | 9 +----- packages/ui/tsconfig.mosaic.json | 4 ++- packages/ui/tsdown.mosaic.config.mts | 32 +++++++++++++------ pnpm-lock.yaml | 22 ++++++------- 10 files changed, 48 insertions(+), 39 deletions(-) create mode 100644 packages/ui/src/mosaic/index.ts diff --git a/packages/ui/package.json b/packages/ui/package.json index 810177a8dd7..18a328b8e3d 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 f05e07c1ec1..e1c23cc0be0 100644 --- a/packages/ui/src/mosaic/components/menu/index.ts +++ b/packages/ui/src/mosaic/components/menu/index.ts @@ -1,2 +1,2 @@ export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu'; -export type { MenuContentProps, MenuItemProps, MenuProps, MenuSeparatorProps, MenuTriggerProps } from './menu'; +export type { MenuContentProps, MenuItemProps, MenuTriggerProps } from './menu'; diff --git a/packages/ui/src/mosaic/components/menu/menu.tsx b/packages/ui/src/mosaic/components/menu/menu.tsx index ecff3266ff5..96ba671c2ca 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, MenuPortalProps, - MenuProps, MenuSeparatorProps, } from '@clerk/headless/menu'; import { Menu as Primitive } from '@clerk/headless/menu'; @@ -16,8 +15,6 @@ import { Icon } from '../icon'; import { reset } from '../reset.styles'; import { styles } 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 20139f5d0ce..5f4bb1b2bb1 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'; @@ -10,8 +9,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 018c91cf01f..727930ec431 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -26,13 +26,7 @@ export type { InputProps } from '../components/input'; export { Item } from '../components/item'; export type { ItemProps } from '../components/item'; export { Menu } from '../components/menu'; -export type { - MenuContentProps, - MenuItemProps, - MenuProps, - MenuSeparatorProps, - MenuTriggerProps, -} from '../components/menu'; +export type { MenuContentProps, MenuItemProps, MenuTriggerProps } from '../components/menu'; export { scrollAreaRoot, scrollAreaVars, scrollAreaViewport } from '../components/scroll-area'; export type { ScrollAreaGutter } from '../components/scroll-area'; export { Spinner } from '../components/spinner'; @@ -45,7 +39,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 f1bbb5dce1f..b42cb3fcadd 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: @@ -1201,9 +1201,6 @@ importers: packages/ui: dependencies: - '@clerk/headless': - specifier: workspace:^ - version: link:../headless '@clerk/localizations': specifier: workspace:^ version: link:../localizations @@ -1231,9 +1228,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 @@ -1262,6 +1256,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) @@ -1286,6 +1283,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) @@ -32137,7 +32137,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 @@ -32145,7 +32145,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: @@ -32228,7 +32228,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 611fc5af2c8c22425117d58ba3f407de849ae3b1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 09:28:46 -0400 Subject: [PATCH 3/9] feat(ui): add custom UserProfile pages to the Mosaic UserButton (#9343) --- .changeset/mosaic-user-button-custom-pages.md | 2 + packages/nextjs/src/experimental/mosaic.ts | 9 +- packages/react/src/experimental/mosaic.ts | 9 +- .../hooks/__tests__/useCustomPages.test.tsx | 212 ++++++++++++++++++ .../ui/src/mosaic/hooks/useCustomPages.tsx | 142 ++++++++++++ .../src/mosaic/hooks/useUserProfilePages.ts | 33 +++ packages/ui/src/mosaic/index.ts | 4 +- .../__tests__/user-button.controller.test.tsx | 25 ++- .../user-button.integration.test.tsx | 47 +++- .../user-button/user-button.controller.tsx | 14 +- .../ui/src/mosaic/user-button/user-button.tsx | 95 +++++--- 11 files changed, 553 insertions(+), 39 deletions(-) create mode 100644 .changeset/mosaic-user-button-custom-pages.md create mode 100644 packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx create mode 100644 packages/ui/src/mosaic/hooks/useCustomPages.tsx create mode 100644 packages/ui/src/mosaic/hooks/useUserProfilePages.ts diff --git a/.changeset/mosaic-user-button-custom-pages.md b/.changeset/mosaic-user-button-custom-pages.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-button-custom-pages.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/nextjs/src/experimental/mosaic.ts b/packages/nextjs/src/experimental/mosaic.ts index 27da5d32e99..1ef439172ce 100644 --- a/packages/nextjs/src/experimental/mosaic.ts +++ b/packages/nextjs/src/experimental/mosaic.ts @@ -13,4 +13,11 @@ * @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'; +export type { + CustomProfileItem, + CustomProfileLink, + CustomProfilePage, + UserButtonProps, + UserButtonUserProfileProps, + UserProfilePageId, +} from '@clerk/react/experimental/mosaic'; diff --git a/packages/react/src/experimental/mosaic.ts b/packages/react/src/experimental/mosaic.ts index 6ffe79f4533..fd6015c5aef 100644 --- a/packages/react/src/experimental/mosaic.ts +++ b/packages/react/src/experimental/mosaic.ts @@ -11,4 +11,11 @@ * @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'; +export type { + CustomProfileItem, + CustomProfileLink, + CustomProfilePage, + UserButtonProps, + UserButtonUserProfileProps, + UserProfilePageId, +} from '@clerk/ui/experimental/mosaic'; diff --git a/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx b/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx new file mode 100644 index 00000000000..e209673b17a --- /dev/null +++ b/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx @@ -0,0 +1,212 @@ +import type { CustomPage } from '@clerk/shared/types'; +import { act, render, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { CustomPagesOptions, CustomProfileItem } from '../useCustomPages'; +import { useCustomPages } from '../useCustomPages'; + +// The bridge's other half lives in clerk-js: `ExternalElementMounter` renders a `div` and hands it to +// `mount`, then hands it back to `unmount` when the profile goes away. These stand in for it, so the +// tests exercise the same handshake the real modal performs. +function mountInto(callback: ((el: HTMLDivElement) => void) | undefined): HTMLDivElement { + const el = document.createElement('div'); + document.body.appendChild(el); + act(() => callback?.(el)); + return el; +} + +function unmountFrom(callback: ((el?: HTMLDivElement) => void) | undefined, el: HTMLDivElement) { + act(() => callback?.(el)); + el.remove(); +} + +let emitted: CustomPage[] | undefined; + +function Harness({ items, order, builtInPages = ['account', 'security'] }: Partial) { + const { customPages, portals } = useCustomPages({ items, order, builtInPages }); + emitted = customPages; + return
{portals}
; +} + +const terms: CustomProfileItem = { + label: 'Terms', + path: 'terms', + icon: terms icon, + content:

Terms body

, +}; + +const docs: CustomProfileItem = { + label: 'Docs', + path: 'docs', + href: 'https://clerk.com/docs', + icon: docs icon, +}; + +beforeEach(() => { + emitted = undefined; +}); + +describe('useCustomPages', () => { + it('sends nothing when there are no custom pages', () => { + render(); + + expect(emitted).toBeUndefined(); + expect(screen.getByTestId('host')).toBeEmptyDOMElement(); + }); + + it('sends a page as its path and a link as its href', () => { + render(); + + expect(emitted?.map(page => page.url)).toEqual(['terms', 'https://clerk.com/docs']); + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']); + }); + + // clerk-js tells a page from a link by which callbacks are present, so content callbacks are what + // make an item a page. A link carrying them would be routed to instead of followed. + it('sends content callbacks for a page and none for a link', () => { + render(); + + const [page, link] = emitted ?? []; + expect(page.mount).toBeTypeOf('function'); + expect(page.unmount).toBeTypeOf('function'); + expect(link.mount).toBeUndefined(); + expect(link.unmount).toBeUndefined(); + }); + + // The same presence check rejects any item missing an icon pair outright, so the callbacks go out + // whether or not there is an icon to put through them. Without this, `icon` could not be optional: + // leaving it off would drop the page from the profile with no explanation. + it('sends the icon callbacks even for an item with no icon', () => { + render(Terms body

}]} />); + + const [page] = emitted ?? []; + expect(page.mountIcon).toBeTypeOf('function'); + expect(page.unmountIcon).toBeTypeOf('function'); + + const el = mountInto(page.mountIcon); + expect(el).toBeEmptyDOMElement(); + }); + + it('renders page content into the element clerk-js hands back', () => { + render(); + + const el = mountInto(emitted?.[0].mount); + + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + }); + + it('renders an icon into its own element, apart from the content', () => { + render(); + + const content = mountInto(emitted?.[0].mount); + const icon = mountInto(emitted?.[0].mountIcon); + + expect(within(icon).getByText('terms icon')).toBeInTheDocument(); + expect(within(content).queryByText('terms icon')).toBeNull(); + }); + + it('keeps each page in the element that asked for it', () => { + const help: CustomProfileItem = { label: 'Help', path: 'help', content:

Help body

}; + render(); + + const first = mountInto(emitted?.[0].mount); + const second = mountInto(emitted?.[1].mount); + + expect(within(first).getByText('Terms body')).toBeInTheDocument(); + expect(within(second).getByText('Help body')).toBeInTheDocument(); + }); + + it('stops rendering content once clerk-js gives the element back', () => { + render(); + + const el = mountInto(emitted?.[0].mount); + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + + unmountFrom(emitted?.[0].unmount, el); + + expect(screen.queryByText('Terms body')).toBeNull(); + }); + + // The profile is opened once with the callbacks from that render, and never handed a later set. + // They have to keep working against the current content, or a page re-rendered while the profile + // is open goes stale. + it('renders updated content through the callbacks the profile was opened with', () => { + const { rerender } = render(); + const el = mountInto(emitted?.[0].mount); + + rerender(Revised terms

}]} />); + + expect(within(el).getByText('Revised terms')).toBeInTheDocument(); + }); + + describe('order', () => { + it('leaves the built-in pages alone when no order is given', () => { + render(); + + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']); + }); + + it('sends the pages in the order it was given', () => { + render( + , + ); + + expect(emitted?.map(page => page.label)).toEqual(['security', 'Terms', 'account', 'Docs']); + }); + + // clerk-js takes a request to move a built-in page as the page's id and nothing else; anything + // more and it reads as a custom page instead. + it('sends a built-in page as its id alone', () => { + render(); + + expect(emitted).toEqual([{ label: 'security' }, { label: 'account' }]); + }); + + // clerk-js puts a built-in page it was not sent *before* every page it was, so leaving one out + // of the order would jump it to the front rather than leave it where it was. + it('sends the pages left out of the order after the ones in it', () => { + render( + , + ); + + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security', 'billing', 'Docs']); + }); + + it('drops an id that belongs to no page', () => { + render( + , + ); + + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security']); + }); + + it('sends a page once even when the order names it twice', () => { + render(); + + expect(emitted?.map(page => page.label)).toEqual(['security', 'account']); + }); + + it('renders a reordered page into the element clerk-js hands back', () => { + render( + , + ); + + const el = mountInto(emitted?.[1].mount); + + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/ui/src/mosaic/hooks/useCustomPages.tsx b/packages/ui/src/mosaic/hooks/useCustomPages.tsx new file mode 100644 index 00000000000..f406a0d3dad --- /dev/null +++ b/packages/ui/src/mosaic/hooks/useCustomPages.tsx @@ -0,0 +1,142 @@ +import type { CustomPage } from '@clerk/shared/types'; +import type { ReactNode } from 'react'; +import { useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; + +/** A page of your own inside the profile, reached from its navigation. */ +export interface CustomProfilePage { + /** Names the page in the profile's navigation. */ + label: string; + /** Where the page lives, relative to the profile root. Absolute URLs are rejected. */ + path: string; + href?: never; + icon?: ReactNode; + /** Rendered as the page itself. */ + content: ReactNode; +} + +/** A row in the profile's navigation that leaves for somewhere else. */ +export interface CustomProfileLink { + /** Names the row in the profile's navigation. */ + label: string; + /** Identifies the row, for ordering. */ + path: string; + /** Where the row goes. */ + href: string; + icon?: ReactNode; + content?: never; +} + +export type CustomProfileItem = CustomProfilePage | CustomProfileLink; + +export interface CustomPagesOptions { + /** Pages and links of the consumer's own. */ + items: CustomProfileItem[] | undefined; + /** The order the profile's navigation should run in, by id. */ + order: readonly string[] | undefined; + /** The profile's own pages, in the order it shows them, minus any this instance has turned off. */ + builtInPages: readonly string[]; +} + +export interface CustomPagesBridge { + /** clerk-js's own custom-page form, ready to pass to `openUserProfile`. */ + customPages: CustomPage[] | undefined; + /** Render these for as long as the profile can be open, or its pages come up blank. */ + portals: ReactNode[]; +} + +const isLink = (item: CustomProfileItem): item is CustomProfileLink => item.href !== undefined; + +/** + * The ids to send, in the order the profile should show them. + * + * clerk-js puts every built-in page it was *not* asked to move ahead of everything it was, so a + * built-in left out of the order has to be sent anyway to keep it behind the pages that were named. + * Ids that match no page are dropped rather than sent: clerk-js would reject them, and does so by + * logging them as invalid page data, which is not what a typo in this list deserves. + */ +function arrange( + order: readonly string[], + items: ReadonlyMap, + builtInPages: readonly string[], +): string[] { + const exists = (id: string) => items.has(id) || builtInPages.includes(id); + const named = [...new Set(order)].filter(exists); + const rest = [...builtInPages, ...items.keys()].filter(id => !named.includes(id)); + return [...named, ...rest]; +} + +function portalInto(containers: ReadonlyMap, id: string, node: ReactNode): ReactNode { + const container = containers.get(id); + return container ? createPortal(node, container, id) : null; +} + +/** + * Bridges custom pages written as React nodes into the DOM callbacks clerk-js takes. + * + * The profile opens in clerk-js's own React root, which cannot render a node from the host app's + * tree. So each page is sent as a `mount`/`unmount` pair: clerk-js renders an empty `div` where the + * page belongs and hands it over, and the host tree portals the content into it from here. The + * portals therefore have to stay mounted in the host tree the whole time the profile is open, which + * is why they come back out rather than being rendered here. + * + * This is the shape of the bridge only for as long as the profile renders outside the host tree. A + * Mosaic profile mounted in-tree renders `content` directly, and none of this survives except the + * props a consumer writes. + */ +export function useCustomPages({ items, order, builtInPages }: CustomPagesOptions): CustomPagesBridge { + const [containers, setContainers] = useState>(new Map()); + + // Keyed by id rather than closing over the element, so the callbacks a profile was opened with keep + // working: the portal re-reads its container from state on every render of the host tree. + const bind = useCallback( + (id: string) => ({ + mount: (el: HTMLDivElement) => setContainers(prev => new Map(prev).set(id, el)), + unmount: () => + setContainers(prev => { + const next = new Map(prev); + next.delete(id); + return next; + }), + }), + [], + ); + + const byId = new Map((items ?? []).map(item => [item.path, item])); + const ids = order?.length ? arrange(order, byId, builtInPages) : [...byId.keys()]; + + if (!ids.length) { + return { customPages: undefined, portals: [] }; + } + + const customPages = ids.map(id => { + const item = byId.get(id); + // A built-in page, which clerk-js moves on nothing but its id. Anything else attached to it and + // it reads as a custom page instead. + if (!item) { + return { label: id }; + } + + // clerk-js decides what an item *is* from which callbacks are present, and drops one missing an + // icon pair as invalid. So the icon callbacks go out whether or not there is an icon to put + // through them; without them, leaving `icon` off would silently cost you the page. + const icon = bind(`icon:${id}`); + const content = isLink(item) ? undefined : bind(`content:${id}`); + + return { + label: item.label, + // A page is routed to by its path; a link is followed to wherever it points. + url: isLink(item) ? item.href : item.path, + mountIcon: icon.mount, + unmountIcon: icon.unmount, + ...(content && { mount: content.mount, unmount: content.unmount }), + }; + }); + + const portals = (items ?? []).flatMap(item => [ + portalInto(containers, `icon:${item.path}`, item.icon), + ...(isLink(item) ? [] : [portalInto(containers, `content:${item.path}`, item.content)]), + ]); + + return { customPages, portals }; +} diff --git a/packages/ui/src/mosaic/hooks/useUserProfilePages.ts b/packages/ui/src/mosaic/hooks/useUserProfilePages.ts new file mode 100644 index 00000000000..88c731d6d9c --- /dev/null +++ b/packages/ui/src/mosaic/hooks/useUserProfilePages.ts @@ -0,0 +1,33 @@ +import { + disabledUserAPIKeysFeature, + disabledUserBillingFeature, +} from '@clerk/shared/internal/clerk-js/componentGuards'; +import { useClerk } from '@clerk/shared/react'; + +import { useMosaicEnvironment } from './useMosaicEnvironment'; + +/** A page the UserProfile brings itself, named by the id its navigation knows it as. */ +export type UserProfilePageId = 'account' | 'security' | 'billing' | 'apiKeys'; + +/** + * The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned + * off. + * + * Ordering a custom page after a built-in one means naming every built-in that follows it, so the + * list has to match what the profile will actually show. It mirrors clerk-js rather than being read + * from it: the profile is not mounted yet at the point this is needed, and it decides its own pages + * from the same environment behind the same guards. + */ +export function useUserProfilePages(): UserProfilePageId[] { + const clerk = useClerk(); + const environment = useMosaicEnvironment(); + + const pages: UserProfilePageId[] = ['account', 'security']; + if (!disabledUserBillingFeature(clerk, environment)) { + pages.push('billing'); + } + if (!disabledUserAPIKeysFeature(clerk, environment)) { + pages.push('apiKeys'); + } + return pages; +} diff --git a/packages/ui/src/mosaic/index.ts b/packages/ui/src/mosaic/index.ts index f029a76256f..f3fcd148d85 100644 --- a/packages/ui/src/mosaic/index.ts +++ b/packages/ui/src/mosaic/index.ts @@ -3,5 +3,7 @@ // `./styles` is the build barrel, and re-exporting it would publish the headless primitive types too. import './styles'; +export type { CustomProfileItem, CustomProfileLink, CustomProfilePage } from './hooks/useCustomPages'; +export type { UserProfilePageId } from './hooks/useUserProfilePages'; export { UserButton } from './user-button/user-button'; -export type { UserButtonProps } from './user-button/user-button'; +export type { UserButtonProps, UserButtonUserProfileProps } from './user-button/user-button'; diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx index 98b0ba9dc00..5057f2e8dfb 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx @@ -1,4 +1,5 @@ import type * as SharedReact from '@clerk/shared/react'; +import type { CustomPage } from '@clerk/shared/types'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -169,8 +170,8 @@ afterEach(() => { vi.clearAllMocks(); }); -function Harness(options: UserButtonControllerOptions = {}) { - const c = useUserButtonController(options); +function Harness({ customPages, ...options }: UserButtonControllerOptions & { customPages?: CustomPage[] } = {}) { + const c = useUserButtonController(options, customPages); if (c.status !== 'ready') { return {c.status}; } @@ -588,6 +589,26 @@ describe('useUserButtonController', () => { expect(openOrganizationProfile).toHaveBeenCalledWith({ getContainer }); }); + // Custom pages are bridged into this DOM-callback form by the container, since it is the layer + // that can render their portals. All the controller owes them is a ride to the modal. + it('hands the profile modal the custom pages it was given', () => { + const customPages = [ + { + label: 'Terms', + url: 'terms', + mount: vi.fn(), + unmount: vi.fn(), + mountIcon: vi.fn(), + unmountIcon: vi.fn(), + }, + ]; + render(); + + fireEvent.click(screen.getByText('manage-account')); + + expect(openUserProfile).toHaveBeenCalledWith({ getContainer, customPages }); + }); + // A URL is the whole opt-in: passing one means navigation, with no mode to remember to pass // alongside it. The two are resolved apart, so routing one profile leaves the other a modal. it('navigates to a profile URL when one is given, and only for that profile', () => { diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx index af3499a53d2..38b98d2260e 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx @@ -1,5 +1,6 @@ import type * as SharedReact from '@clerk/shared/react'; -import { render, screen, waitFor, within } from '@testing-library/react'; +import type { CustomPage } from '@clerk/shared/types'; +import { act as reactAct, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -84,6 +85,8 @@ vi.mock('@clerk/shared/react', async importOriginal => { displayConfig: { afterSwitchSessionUrl: '/after-switch' }, authConfig: { singleSessionMode }, organizationSettings: { enabled: organizationsEnabled }, + commerceSettings: { billing: { user: { enabled: false } } }, + apiKeysSettings: { user_api_keys_enabled: false }, }, }), }; @@ -390,6 +393,48 @@ describe('UserButton (connected)', () => { await waitFor(() => expect(popup()).toBeNull()); }); + // The whole round trip for a custom page: the prop a consumer writes, through the bridge, out to + // the callbacks clerk-js is handed, and back into the element clerk-js renders for the page. The + // popover has closed by then, so this also covers the portals outliving what opened them. + it('renders a custom page into the element the opened profile hands back', async () => { + renderUserButton({ + userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}] }, + }); + const act = await open(); + + await accountAction(act, 'Manage account'); + await waitFor(() => expect(popup()).toBeNull()); + + const { customPages } = openUserProfile.mock.calls[0][0]; + expect(customPages).toHaveLength(1); + expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' }); + + // Stands in for clerk-js's `ExternalElementMounter`, which renders this `div` where the page goes. + const el = document.createElement('div'); + document.body.appendChild(el); + reactAct(() => { + customPages[0].mount(el); + }); + + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + }); + + it('opens the profile with its pages in the order it was given', async () => { + renderUserButton({ + userProfileProps: { + customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}], + pageOrder: ['account', 'terms'], + }, + }); + const act = await open(); + + await accountAction(act, 'Manage account'); + await waitFor(() => expect(popup()).toBeNull()); + + const { customPages } = openUserProfile.mock.calls[0][0]; + expect(customPages.map((page: CustomPage) => page.label)).toEqual(['account', 'Terms', 'security']); + }); + it('inviting members opens the InviteMembers modal and closes the popover', async () => { renderUserButton(); const act = await open(); diff --git a/packages/ui/src/mosaic/user-button/user-button.controller.tsx b/packages/ui/src/mosaic/user-button/user-button.controller.tsx index ac802fc4806..bd5e36a6687 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -1,6 +1,6 @@ import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user'; import { useClerk, useOrganization, usePortalRoot, useSession, useUser } from '@clerk/shared/react'; -import type { OrganizationResource, UserResource } from '@clerk/shared/types'; +import type { CustomPage, OrganizationResource, UserResource } from '@clerk/shared/types'; import { populateParamFromObject } from '../../contexts/utils'; import { useOrganizationListInView } from '../../hooks/useOrganizationListInView'; @@ -124,7 +124,15 @@ function toSession(sessionId: string, user: UserResource): UserButtonSession { }; } -export function useUserButtonController(options?: UserButtonControllerOptions): UserButtonController { +/** + * @param userProfileCustomPages - The consumer's custom pages, already bridged into clerk-js's + * DOM-callback form. The container owns that conversion because it is the layer that can render + * the portals behind it, so they arrive here ready to forward and stay out of the public options. + */ +export function useUserButtonController( + options?: UserButtonControllerOptions, + userProfileCustomPages?: CustomPage[], +): UserButtonController { const { isLoaded: isUserLoaded, user } = useUser(); const { isLoaded: isSessionLoaded, session } = useSession(); const { isLoaded: isOrgLoaded, organization } = useOrganization(); @@ -145,7 +153,7 @@ export function useUserButtonController(options?: UserButtonControllerOptions): const manageAccount = openOrNavigate({ url: options?.userProfileUrl, mode: options?.userProfileMode, - openModal: () => clerk.openUserProfile({ getContainer }), + openModal: () => clerk.openUserProfile({ getContainer, customPages: userProfileCustomPages }), buildUrl: () => clerk.buildUserProfileUrl(), navigate: router.navigate, }); diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index 77ba5d4d96f..84540cc403a 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -3,23 +3,41 @@ import type { ReactElement } from 'react'; import { useState } from 'react'; +import type { CustomProfileItem } from '../hooks/useCustomPages'; +import { useCustomPages } from '../hooks/useCustomPages'; import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment'; import { useSpinDelay } from '../hooks/useSpinDelay'; +import type { UserProfilePageId } from '../hooks/useUserProfilePages'; +import { useUserProfilePages } from '../hooks/useUserProfilePages'; import type { UserButtonController, UserButtonControllerOptions } from './user-button.controller'; import { useUserButtonController } from './user-button.controller'; import type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types'; import type { UserButtonTriggerProps } from './user-button.view'; import { userButtonBusyKeys, UserButtonView } from './user-button.view'; +/** Configures the UserProfile this button opens. */ +export interface UserButtonUserProfileProps { + /** Pages and links of your own, added to the profile's navigation. */ + customPages?: CustomProfileItem[]; + /** + * The order the profile's navigation runs in, by id: a built-in page's id, or a custom entry's + * `path`. Anything left out follows the pages named here. The first page is the one the profile + * opens on, so it cannot be a link. + */ + pageOrder?: (UserProfilePageId | (string & {}))[]; +} + /** * Everything `` takes: where its profile surfaces open (`UserButtonControllerOptions`), - * what the trigger shows (`UserButtonTriggerProps`), and the app's own rows at the foot of the menu - * (`UserButtonMenuProps`). + * what the trigger shows (`UserButtonTriggerProps`), the app's own rows at the foot of the menu + * (`UserButtonMenuProps`), and the profile it opens (`UserButtonUserProfileProps`). */ export type UserButtonProps = UserButtonControllerOptions & UserButtonTriggerProps & UserButtonMenuProps & - UserButtonModeProps; + UserButtonModeProps & { + userProfileProps?: UserButtonUserProfileProps; + }; /** The one action in flight: which affordance owns it, and what the surface froze on to run it. */ interface PendingAction { @@ -65,10 +83,14 @@ interface PendingAction { * ``` * * @example - * `customMenuItems` adds your own rows to the foot of the menu, each one either an `onClick` action - * or an `href` link, and `menuItemOrder` names the order the foot's rows run in. + * `customPages` adds your own pages to the profile this button opens; `customMenuItems` adds your + * own rows to the foot of the menu, each one either an `onClick` action or an `href` link. * ```tsx * , content: }], + * pageOrder: ['account', 'usage', 'security'], + * }} * customMenuItems={[ * { id: 'docs', label: 'Documentation', icon: , href: 'https://example.com/docs' }, * { id: 'support', label: 'Contact support', icon: , onClick: () => openSupportChat() }, @@ -83,11 +105,21 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { renderPlanBadge, mode: requestedMode, modePriority, + userProfileProps, customMenuItems, menuItemOrder, ...options } = props; - const controller = useUserButtonController(options); + // The profile opens in clerk-js's own React root, so its custom pages reach it as portals rendered + // from here. They have to outlive the popover that opened it, and the button's own data with it, + // which is why they hang off the container rather than anything the popover renders. + const builtInPages = useUserProfilePages(); + const { customPages, portals } = useCustomPages({ + items: userProfileProps?.customPages, + order: userProfileProps?.pageOrder, + builtInPages, + }); + const controller = useUserButtonController(options, customPages); const [open, setOpen] = useState(false); const [action, setAction] = useState(null); @@ -106,7 +138,7 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { // promised to people who are never going to get one. `` is where an app that knows // its own nav puts a placeholder. if (controller.status !== 'ready') { - return null; + return <>{portals}; } const close = () => setOpen(false); @@ -177,28 +209,31 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { } = action?.snapshot ?? controller; return ( - + <> + + {portals} + ); } From 0a85af8f9710c7698414ed491f06ccdd56cca5a4 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 09:57:39 -0400 Subject: [PATCH 4/9] chore(repo): dedupe the lockfile --- pnpm-lock.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b42cb3fcadd..ac945ce8c5f 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: @@ -8343,6 +8343,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-mediaquery@0.1.2: resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==} @@ -32137,7 +32138,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 @@ -32145,7 +32146,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: @@ -32228,7 +32229,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 942c1fb844bea66ced009249875a39b24ab5d9d9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 15:53:21 -0400 Subject: [PATCH 5/9] 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/nextjs/src/experimental/mosaic.ts | 9 +-------- packages/react/src/experimental/mosaic.ts | 9 +-------- packages/ui/src/mosaic/index.ts | 4 +--- packages/ui/src/mosaic/user-button/user-button.tsx | 2 +- 4 files changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/nextjs/src/experimental/mosaic.ts b/packages/nextjs/src/experimental/mosaic.ts index 1ef439172ce..27da5d32e99 100644 --- a/packages/nextjs/src/experimental/mosaic.ts +++ b/packages/nextjs/src/experimental/mosaic.ts @@ -13,11 +13,4 @@ * @experimental The surface and the components behind it are subject to change. */ export { UserButton } from '@clerk/react/experimental/mosaic'; -export type { - CustomProfileItem, - CustomProfileLink, - CustomProfilePage, - UserButtonProps, - UserButtonUserProfileProps, - UserProfilePageId, -} from '@clerk/react/experimental/mosaic'; +export type { UserButtonProps } from '@clerk/react/experimental/mosaic'; diff --git a/packages/react/src/experimental/mosaic.ts b/packages/react/src/experimental/mosaic.ts index fd6015c5aef..6ffe79f4533 100644 --- a/packages/react/src/experimental/mosaic.ts +++ b/packages/react/src/experimental/mosaic.ts @@ -11,11 +11,4 @@ * @experimental The surface and the components behind it are subject to change. */ export { UserButton } from '@clerk/ui/experimental/mosaic'; -export type { - CustomProfileItem, - CustomProfileLink, - CustomProfilePage, - UserButtonProps, - UserButtonUserProfileProps, - UserProfilePageId, -} from '@clerk/ui/experimental/mosaic'; +export type { UserButtonProps } from '@clerk/ui/experimental/mosaic'; diff --git a/packages/ui/src/mosaic/index.ts b/packages/ui/src/mosaic/index.ts index f3fcd148d85..f029a76256f 100644 --- a/packages/ui/src/mosaic/index.ts +++ b/packages/ui/src/mosaic/index.ts @@ -3,7 +3,5 @@ // `./styles` is the build barrel, and re-exporting it would publish the headless primitive types too. import './styles'; -export type { CustomProfileItem, CustomProfileLink, CustomProfilePage } from './hooks/useCustomPages'; -export type { UserProfilePageId } from './hooks/useUserProfilePages'; export { UserButton } from './user-button/user-button'; -export type { UserButtonProps, UserButtonUserProfileProps } from './user-button/user-button'; +export type { UserButtonProps } from './user-button/user-button'; diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index 84540cc403a..749f34190b1 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -56,7 +56,7 @@ interface PendingAction { * * @example * ```tsx - * import { UserButton } from '@clerk/ui/mosaic'; + * import { UserButton } from '@clerk/nextjs/experimental/mosaic'; * * * ``` From 4a03497d5b4cb8f24f74753eb2a2e7f93ceedb26 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 17:15:43 -0400 Subject: [PATCH 6/9] 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 1cedaa36e2b4986e0f5266e5fb55da8508cc1c88 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 18:56:39 -0400 Subject: [PATCH 7/9] 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 b82bcc206c69a44b719e188d405b0de1cbefe119 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 18:56:43 -0400 Subject: [PATCH 8/9] 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 3b818b7738c..8f1bf28bec5 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 68e46fb008f9a3b235778f7ca0c1d66be2c393b8 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 18:56:45 -0400 Subject: [PATCH 9/9] 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 b15787754f8..4885138c602 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 ac945ce8c5f..acca6e4d917 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