From a884f0e3ade402519372e5c8bd89a04f3606aa88 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 4 Aug 2026 09:54:20 -0400 Subject: [PATCH 01/11] 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 cba2e401807..8025ea783de 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 e0b589c3061..50216f77a8f 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 731b0b2e873..de3ded56ec1 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 8fef2cf5974..edc7234c8e1 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 71b032f830b34daaff4b868e543550f3c235f651 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 5 Aug 2026 11:31:33 -0400 Subject: [PATCH 02/11] 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 de3ded56ec1..2fe720581d1 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 edc7234c8e1..685ecc907a5 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 fb1b8cd84c3f415e0f67a0b9bd77dbb536354af4 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 09:57:39 -0400 Subject: [PATCH 03/11] 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 685ecc907a5..ff6c1088725 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 6d99a0602c7dc67c2629226081ab86d1759ddf6b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 15:53:21 -0400 Subject: [PATCH 04/11] 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 5a6fccefa96..356a83311df 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -51,7 +51,7 @@ export type UserButtonProps = UserButtonControllerOptions & * * @example * ```tsx - * import { UserButton } from '@clerk/ui/mosaic'; + * import { UserButton } from '@clerk/nextjs/experimental/mosaic'; * * * ``` From 04c4240daa667367a9d63380c81fb73251e7820b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 17:15:43 -0400 Subject: [PATCH 05/11] 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 a5007da1ca3e40c8a97d5e2fc2b2e6f478445474 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 18:56:39 -0400 Subject: [PATCH 06/11] 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 17dff50dc388ac94405a5c1310efff0cce6e008a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 18:56:43 -0400 Subject: [PATCH 07/11] 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 dcb47b4724eb4c76b9ce182ab13836736f34a290 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 18:56:45 -0400 Subject: [PATCH 08/11] 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 50216f77a8f..7db1a2bcf0c 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 ff6c1088725..10bbb7df773 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -922,9 +922,6 @@ importers: '@clerk/shared': specifier: workspace:^ version: link:../shared - '@clerk/ui': - specifier: workspace:^ - version: link:../ui react: specifier: 18.3.1 version: 18.3.1 @@ -938,6 +935,9 @@ importers: '@clerk/localizations': specifier: workspace:* version: link:../localizations + '@clerk/ui': + specifier: workspace:* + version: link:../ui '@types/semver': specifier: ^7.7.1 version: 7.7.1 From 1a0216b2b55aeabb10c583ec5877f471ad6fc501 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 7 Aug 2026 10:26:41 -0400 Subject: [PATCH 09/11] 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 22c94b069019b520c1449401e6414254a05b49b9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 7 Aug 2026 15:30:30 -0400 Subject: [PATCH 10/11] 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 b80d48af3e7c0e3cbb0f329839bfbe957c485067 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 7 Aug 2026 16:17:48 -0400 Subject: [PATCH 11/11] 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' }) }); }); });