From d43c6d961d87000a0b829d9bea6d0528503ffd2c Mon Sep 17 00:00:00 2001
From: Alex Carpenter
Date: Mon, 26 Oct 2026 10:08:38 -0400
Subject: [PATCH] feat(mosaic): export UserProfile and OrganizationProfile and
cover React concurrent features in e2e
---
.changeset/mosaic-export-profiles.md | 5 +
.../src/app/concurrency-harness.tsx | 79 +++++
.../src/app/organization-profile/page.tsx | 15 +
.../src/app/user-button/page.tsx | 11 +
.../src/app/user-profile/page.tsx | 13 +
integration/tests/mosaic/concurrency.test.ts | 272 ++++++++++++++++++
packages/mosaic/src/index.ts | 4 +
7 files changed, 399 insertions(+)
create mode 100644 .changeset/mosaic-export-profiles.md
create mode 100644 integration/templates/next-app-router-mosaic/src/app/concurrency-harness.tsx
create mode 100644 integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx
create mode 100644 integration/templates/next-app-router-mosaic/src/app/user-button/page.tsx
create mode 100644 integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx
create mode 100644 integration/tests/mosaic/concurrency.test.ts
diff --git a/.changeset/mosaic-export-profiles.md b/.changeset/mosaic-export-profiles.md
new file mode 100644
index 00000000000..5a316235bcc
--- /dev/null
+++ b/.changeset/mosaic-export-profiles.md
@@ -0,0 +1,5 @@
+---
+'@clerk/mosaic': patch
+---
+
+Export the `UserProfile` and `OrganizationProfile` components.
diff --git a/integration/templates/next-app-router-mosaic/src/app/concurrency-harness.tsx b/integration/templates/next-app-router-mosaic/src/app/concurrency-harness.tsx
new file mode 100644
index 00000000000..48aba21a94d
--- /dev/null
+++ b/integration/templates/next-app-router-mosaic/src/app/concurrency-harness.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+import { MosaicProvider } from '@clerk/mosaic';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import type { ReactNode } from 'react';
+import { Activity, startTransition, Suspense, use, useRef, useState } from 'react';
+
+type HarnessPage = { label: string; path: string; content: ReactNode };
+
+export function ConcurrencyHarness({
+ extra,
+ children,
+}: {
+ extra: string | undefined;
+ children: (customPages: HarnessPage[]) => ReactNode;
+}) {
+ const pathname = usePathname();
+ const [hidden, setHidden] = useState(false);
+ const [transitions, setTransitions] = useState(0);
+ const [customPageShown, setCustomPageShown] = useState(true);
+ const [suspension, setSuspension] = useState | null>(null);
+ const resume = useRef<() => void>(() => {});
+
+ function suspendHost() {
+ setSuspension(
+ new Promise(resolve => {
+ resume.current = resolve;
+ }),
+ );
+ }
+
+ const customPages = [
+ ...(customPageShown
+ ? [
+ {
+ label: 'Custom page',
+ path: 'custom-page',
+ content: (
+ <>
+ profile-transitions-{transitions}
+ Leave profile
+ >
+ ),
+ },
+ ]
+ : []),
+ ...(extra
+ ? [{ label: 'Extra page', path: 'extra-page', content: Drop extra page }]
+ : []),
+ ];
+
+ return (
+
+
+
+
+
+
+ Restore extra page
+
+
+ host-transitions-{transitions}
+ {customPageShown ? 'custom-page-shown' : 'custom-page-dropped'}
+ {extra ? 'extra-page-shown' : 'extra-page-dropped'}
+ host-suspended
}>
+
+ {children(customPages)}
+
+
+ );
+}
+
+function SuspendOn({ promise }: { promise: Promise | null }) {
+ if (promise) {
+ use(promise);
+ }
+ return null;
+}
diff --git a/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx b/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx
new file mode 100644
index 00000000000..81ecf614b10
--- /dev/null
+++ b/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx
@@ -0,0 +1,15 @@
+'use client';
+
+import { OrganizationProfile } from '@clerk/mosaic';
+import { use } from 'react';
+
+import { ConcurrencyHarness } from '../concurrency-harness';
+
+export default function Page({ searchParams }: { searchParams: Promise<{ extra?: string }> }) {
+ const { extra } = use(searchParams);
+ return (
+
+ {customPages => }
+
+ );
+}
diff --git a/integration/templates/next-app-router-mosaic/src/app/user-button/page.tsx b/integration/templates/next-app-router-mosaic/src/app/user-button/page.tsx
new file mode 100644
index 00000000000..cab170645f7
--- /dev/null
+++ b/integration/templates/next-app-router-mosaic/src/app/user-button/page.tsx
@@ -0,0 +1,11 @@
+'use client';
+
+import { UserButton } from '@clerk/mosaic';
+import { use } from 'react';
+
+import { ConcurrencyHarness } from '../concurrency-harness';
+
+export default function Page({ searchParams }: { searchParams: Promise<{ extra?: string }> }) {
+ const { extra } = use(searchParams);
+ return {() => };
+}
diff --git a/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx b/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx
new file mode 100644
index 00000000000..885b7c85695
--- /dev/null
+++ b/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { UserProfile } from '@clerk/mosaic';
+import { use } from 'react';
+
+import { ConcurrencyHarness } from '../concurrency-harness';
+
+export default function Page({ searchParams }: { searchParams: Promise<{ extra?: string }> }) {
+ const { extra } = use(searchParams);
+ return (
+ {customPages => }
+ );
+}
diff --git a/integration/tests/mosaic/concurrency.test.ts b/integration/tests/mosaic/concurrency.test.ts
new file mode 100644
index 00000000000..4aeeb6eb5b4
--- /dev/null
+++ b/integration/tests/mosaic/concurrency.test.ts
@@ -0,0 +1,272 @@
+import type { Organization } from '@clerk/backend';
+import type { BrowserContext, Locator, Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+
+import type { FakeUser } from '../../testUtils';
+import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
+
+type Surface = {
+ name: string;
+ path: string;
+ navigation: string;
+ secondPage: string;
+};
+
+const surfaces: Surface[] = [
+ {
+ name: 'UserProfile',
+ path: '/user-profile',
+ navigation: 'User profile',
+ secondPage: 'Security',
+ },
+ {
+ name: 'OrganizationProfile',
+ path: '/organization-profile',
+ navigation: 'Organization',
+ secondPage: 'Members',
+ },
+];
+
+testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })(
+ 'Mosaic under React concurrent features @mosaic',
+ ({ app }) => {
+ let fakeUser: FakeUser;
+ let organization: Organization;
+ const cleanups: Array<() => Promise> = [];
+
+ test.beforeAll(async () => {
+ const u = await createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser(test);
+ cleanups.push(() => fakeUser.deleteIfExists());
+ const user = await u.services.users.createBapiUser(fakeUser);
+ organization = await u.services.clerk.organizations.createOrganization({
+ name: `Mosaic ${Date.now()}`,
+ createdBy: user.id,
+ });
+ cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id));
+ });
+
+ test.afterAll(async () => {
+ const results = await Promise.allSettled(cleanups.map(cleanup => cleanup()));
+ await app.teardown();
+ const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : []));
+ if (failures.length > 0) {
+ throw new AggregateError(failures, 'Mosaic concurrency cleanup failed');
+ }
+ });
+
+ async function signIn({ page, context }: { page: Page; context: BrowserContext }, path: string) {
+ const u = await createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+ await page.evaluate(id => window.Clerk?.setActive({ organization: id }), organization.id);
+ await u.page.goToRelative(path);
+ await u.page.waitForClerkJsLoaded();
+ return u;
+ }
+
+ for (const surface of surfaces) {
+ test.describe(surface.name, () => {
+ const tab = (page: Page, name: string) =>
+ page.getByRole('navigation', { name: surface.navigation }).getByRole('tab', { name });
+
+ async function openSurface({ page, context }: { page: Page; context: BrowserContext }, query = '') {
+ await signIn({ page, context }, `${surface.path}${query}`);
+ await expect(tab(page, surface.secondPage)).toBeVisible();
+ }
+
+ test('a host transition commits while a page is animating out', async ({ page, context }) => {
+ await openSurface({ page, context });
+
+ await tab(page, surface.secondPage).click();
+ await host(page, 'Host transition');
+
+ await expect(page.getByText('host-transitions-1')).toBeVisible();
+ await tab(page, 'Custom page').click();
+ await expect(page.getByText('profile-transitions-1')).toBeVisible();
+ });
+
+ test('hiding it in an Activity keeps the open page', async ({ page, context }) => {
+ await openSurface({ page, context });
+
+ await tab(page, surface.secondPage).click();
+ await host(page, 'Hide surface');
+ await expect(tab(page, surface.secondPage)).toBeHidden();
+ await host(page, 'Show surface');
+
+ await expect(tab(page, surface.secondPage)).toHaveAttribute('aria-selected', 'true');
+ });
+
+ test('restoring a dropped custom page returns to it', async ({ page, context }) => {
+ await openSurface({ page, context });
+
+ await tab(page, 'Custom page').click();
+ await host(page, 'Drop custom page');
+ await expect(tab(page, 'Custom page')).toHaveCount(0);
+ await host(page, 'Restore custom page');
+
+ await expect(page.getByText('custom-page-shown')).toBeVisible();
+ await expect(tab(page, 'Custom page')).toHaveAttribute('aria-selected', 'true');
+ });
+
+ test('restoring a dropped custom page in a host transition returns to it', async ({ page, context }) => {
+ await openSurface({ page, context });
+
+ await tab(page, 'Custom page').click();
+ await host(page, 'Drop custom page');
+ await expect(tab(page, 'Custom page')).toHaveCount(0);
+ await host(page, 'Restore custom page in transition');
+
+ await expect(page.getByText('custom-page-shown')).toBeVisible();
+ await expect(tab(page, 'Custom page')).toHaveAttribute('aria-selected', 'true');
+ });
+
+ test('a Next.js navigation that restores a dropped custom page returns to it', async ({ page, context }) => {
+ await openSurface({ page, context }, '?extra=1');
+
+ await tab(page, 'Extra page').click();
+ await page.getByRole('link', { name: 'Drop extra page' }).click();
+ await expect(page.getByText('extra-page-dropped')).toBeVisible();
+ await page.getByRole('link', { name: 'Restore extra page' }).dispatchEvent('click');
+
+ await expect(page.getByText('extra-page-shown')).toBeVisible();
+ await expect(tab(page, 'Extra page')).toHaveAttribute('aria-selected', 'true');
+ });
+
+ test('a Next.js navigation from inside it leaves and comes back', async ({ page, context }) => {
+ await openSurface({ page, context });
+
+ await tab(page, 'Custom page').click();
+ await page.getByRole('link', { name: 'Leave profile' }).click();
+ await expect(page.getByText('custom-link-target')).toBeVisible();
+
+ await page.goBack();
+ await tab(page, surface.secondPage).click();
+ await expect(tab(page, surface.secondPage)).toHaveAttribute('aria-selected', 'true');
+ });
+ });
+ }
+
+ test.describe('UserProfile add-email flow', () => {
+ const verifyDialog = (page: Page) => page.getByRole('dialog', { name: 'Verify your email' });
+
+ async function startAddingEmail({ page, context }: { page: Page; context: BrowserContext }) {
+ await signIn({ page, context }, '/user-profile');
+ const emailAddress = `mosaic-${Date.now()}+clerk_test@clerkcookie.com`;
+ await page.getByRole('button', { name: 'Add email' }).click();
+ await page.getByRole('textbox', { name: 'Email' }).fill(emailAddress);
+ await page.getByRole('button', { name: 'Continue' }).click();
+ await expect(verifyDialog(page)).toBeVisible();
+ return emailAddress;
+ }
+
+ async function finishAddingEmail(page: Page, emailAddress: string) {
+ const code = verifyDialog(page).getByRole('textbox', { name: 'Verification code' });
+ await expect(code).not.toHaveAttribute('aria-disabled');
+ await code.click();
+ await page.keyboard.type('424242');
+ await expect(verifyDialog(page)).toBeHidden();
+ await page.waitForFunction(
+ address =>
+ window.Clerk?.user?.emailAddresses.find(email => email.emailAddress === address)?.verification.status ===
+ 'verified',
+ emailAddress,
+ );
+ }
+
+ test('hiding it in an Activity keeps the flow on its step', async ({ page, context }) => {
+ const emailAddress = await startAddingEmail({ page, context });
+
+ await hideAndShow(page, verifyDialog(page));
+
+ await expect(verifyDialog(page)).toBeVisible();
+ await finishAddingEmail(page, emailAddress);
+ });
+
+ test('a host Suspense boundary re-suspending keeps the flow on its step', async ({ page, context }) => {
+ const emailAddress = await startAddingEmail({ page, context });
+
+ await suspendAndResume(page);
+
+ await expect(verifyDialog(page)).toBeVisible();
+ await finishAddingEmail(page, emailAddress);
+ });
+ });
+
+ test.describe('OrganizationProfile delete confirmation', () => {
+ const deleteDialog = (page: Page) => page.getByRole('alertdialog', { name: 'Delete organization' });
+
+ async function openDeleteDialog({ page, context }: { page: Page; context: BrowserContext }) {
+ await signIn({ page, context }, '/organization-profile');
+ await page.getByRole('button', { name: 'Delete organization' }).click();
+ await expect(deleteDialog(page)).toBeVisible();
+ }
+
+ test('hiding it in an Activity keeps the dialog open', async ({ page, context }) => {
+ await openDeleteDialog({ page, context });
+
+ await hideAndShow(page, deleteDialog(page));
+
+ await expect(deleteDialog(page)).toBeVisible();
+ });
+
+ test('a host Suspense boundary re-suspending keeps the dialog open', async ({ page, context }) => {
+ await openDeleteDialog({ page, context });
+
+ await suspendAndResume(page);
+
+ await expect(deleteDialog(page)).toBeVisible();
+ });
+ });
+
+ test.describe('UserButton popup', () => {
+ async function openPopup({ page, context }: { page: Page; context: BrowserContext }) {
+ await signIn({ page, context }, '/user-button');
+ await userButtonTrigger(page).click();
+ await expect(userButtonPopup(page)).toBeVisible();
+ }
+
+ test('hiding it in an Activity keeps the popup open', async ({ page, context }) => {
+ await openPopup({ page, context });
+
+ await hideAndShow(page, userButtonPopup(page));
+
+ await expect(userButtonPopup(page)).toBeVisible();
+ });
+
+ test('a host Suspense boundary re-suspending keeps the popup open', async ({ page, context }) => {
+ await openPopup({ page, context });
+
+ await suspendAndResume(page);
+
+ await expect(userButtonPopup(page)).toBeVisible();
+ });
+ });
+ },
+);
+
+function userButtonTrigger(page: Page) {
+ return page.getByRole('button', { name: /^Open account menu for / });
+}
+
+function userButtonPopup(page: Page) {
+ return page.getByRole('dialog', { name: 'Account' });
+}
+
+async function host(page: Page, name: string) {
+ await page.getByRole('button', { name, exact: true }).dispatchEvent('click');
+}
+
+async function hideAndShow(page: Page, hiddenWhileAway: Locator) {
+ await host(page, 'Hide surface');
+ await expect(hiddenWhileAway).toBeHidden();
+ await host(page, 'Show surface');
+}
+
+async function suspendAndResume(page: Page) {
+ await host(page, 'Suspend host');
+ await expect(page.getByText('host-suspended')).toBeVisible();
+ await host(page, 'Resume host');
+ await expect(page.getByText('host-suspended')).toBeHidden();
+}
diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts
index b9690e71e22..de1b73c5707 100644
--- a/packages/mosaic/src/index.ts
+++ b/packages/mosaic/src/index.ts
@@ -4,6 +4,10 @@ export type { UserButtonProps } from './features/user-button/user-button';
export { UserButton } from './features/user-button/user-button';
export type { OrganizationSwitcherProps } from './features/organization-switcher/organization-switcher';
export { OrganizationSwitcher } from './features/organization-switcher/organization-switcher';
+export type { UserProfileProps } from './features/user-profile/user-profile';
+export { UserProfile } from './features/user-profile/user-profile';
+export type { OrganizationProfileProps } from './features/organization-profile/organization-profile';
+export { OrganizationProfile } from './features/organization-profile/organization-profile';
export { MosaicProvider } from './mosaic-provider';
export type { PortalContainer, PortalProviderProps } from './primitives/portal';
export { PortalProvider } from './primitives/portal';