From 1bfff55c28b52de4ef906ede093c0e59d4a2fc87 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Thu, 25 Jun 2026 12:03:12 -0300 Subject: [PATCH 1/2] fix(ui): keep ConfigureSSO wizard mounted while security page data refetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The organization Security page short-circuited to a page-level loading overview whenever its enterprise-connection data was loading, regardless of whether the SSO configuration wizard was open. When a background refetch flipped the loading flag mid-configuration (for example the test-runs query cold-loading right after the connection is configured), the open wizard unmounted and, on remount, reseated to its initial step — dropping the user back to the Domains or Test step and losing their place. Scope the loading overview to the overview view only. Once the wizard is open the connection data is already present and stays warm, and each wizard step renders its own loading state, so a transient refetch no longer tears the wizard down. Adds a regression test that drives the wizard to a later step and asserts it stays put across a loading toggle. --- .../OrganizationSecurityPage.tsx | 8 +- ...nizationSecurityPageWizardLoading.test.tsx | 145 ++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPageWizardLoading.test.tsx diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx index 3e13482ea1c..66ea03774f0 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx @@ -47,7 +47,13 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag setView('wizard'); }; - if (isLoading) { + // Gate the page-level loading overview to the overview view only. A wizard is + // only ever opened after the overview has settled (it gates on `isLoading`), + // so once `view === 'wizard'` the connection data is present and stays warm; a + // later `isLoading` flip (e.g. the test-runs query cold-loading after a + // configure write) must not tear the open wizard down and reseat it — each + // wizard step owns its own loading UI. + if (isLoading && view === 'overview') { return ( { + let loading = false; + const listeners = new Set<() => void>(); + return { + get: () => loading, + set: (next: boolean) => { + loading = next; + listeners.forEach(l => l()); + }, + subscribe: (l: () => void) => { + listeners.add(l); + return () => listeners.delete(l); + }, + }; +}); + +// An active, fully-configured connection with all domains verified and a +// successful test run. Every wizard step is reachable, so the furthest-reachable +// seed is the last step (`activate`). +const activeConnection = { + id: 'ent_1', + name: 'clerk.com', + provider: 'saml_okta', + active: true, + organizationId: 'Org1', + domains: ['clerk.com'], + samlConnection: { + idpSsoUrl: 'https://idp.example.com/sso', + idpEntityId: 'https://idp.example.com/entity', + idpCertificate: 'CERT', + }, +} as any; + +const verifiedDomain = { + id: 'dmn_verified', + name: 'clerk.com', + organizationId: 'Org1', + enrollmentMode: 'enterprise_sso', + ownershipVerification: { status: 'verified', strategy: 'txt' }, +} as any; + +const noop = () => Promise.resolve(undefined); + +// Mock the umbrella hook so the test owns `isLoading` and the connection state +// directly, while the real OrganizationSecurityPage / ConfigureSSOWizard / Wizard +// render. This isolates the bug to the page's loading-vs-view gating. +vi.mock('../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection', () => ({ + useOrganizationEnterpriseConnection: () => { + const isLoading = React.useSyncExternalStore(loadingStore.subscribe, loadingStore.get, loadingStore.get); + return { + isLoading, + user: { primaryEmailAddress: { emailAddress: 'test@clerk.com' } }, + session: {}, + organization: { name: 'Org1' }, + enterpriseConnection: activeConnection, + organizationEnterpriseConnection: buildOrganizationEnterpriseConnection({ + connection: activeConnection, + hasSuccessfulTestRun: true, + }), + enterpriseConnectionMutations: { + createConnection: noop, + changeProvider: noop, + updateConnection: noop, + setConnectionActive: noop, + deleteConnection: noop, + createTestRun: noop, + }, + testRuns: { + rows: [{ id: 'run_1', status: 'success' }], + totalCount: 1, + isLoading: false, + isFetching: false, + isPolling: false, + page: 1, + setPage: () => {}, + refresh: noop, + }, + organizationDomains: [verifiedDomain], + organizationDomainMutations: { + createDomain: noop, + prepareOwnershipVerification: noop, + attemptOwnershipVerification: noop, + revalidate: noop, + }, + }; + }, +})); + +import { OrganizationSecurityPage } from '../OrganizationSecurityPage'; + +const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + +const withSecurityPageFixtures = (f: Parameters[0]>[0]) => { + f.withEnterpriseSso({ selfServeSSO: true }); + f.withEmailAddress(); + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', permissions: ['org:sys_entconns:manage'] }], + }); +}; + +describe('OrganizationSecurityPage — wizard survives a mid-flow loading toggle', () => { + it('keeps the open wizard on its current step when isLoading flips true→false', async () => { + loadingStore.set(false); + const { wrapper } = await createFixtures(withSecurityPageFixtures); + + const { userEvent } = render(, { wrapper }); + + // Enter the wizard from the overview via Edit, which forces the first step. + await userEvent.click(await screen.findByRole('button', { name: /open menu/i })); + await userEvent.click(await screen.findByRole('menuitem', { name: 'Edit' })); + expect(await screen.findByRole('heading', { name: /add SSO domains/i })).toBeInTheDocument(); + + // Navigate forward to the Activate step via the breadcrumb (reachable because + // the connection is active). This puts the user on a step OTHER than the + // forced seed, so a reseat is observable. + await userEvent.click(screen.getByRole('button', { name: /^Activate$/ })); + expect(await screen.findByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument(); + + // A transient refetch: isLoading flips true then back to false while the user + // is mid-wizard. The wizard must NOT unmount and reseat. + act(() => loadingStore.set(true)); + act(() => loadingStore.set(false)); + + // The wizard stays on the Activate step; it did not snap back to the forced + // first step (Domains). On the unfixed page-level gate the wizard unmounts + // during the `true` frame and remounts at the forced first step, so the + // Activate heading is gone and "Add SSO domains" is shown instead. + expect(screen.getByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /add SSO domains/i })).not.toBeInTheDocument(); + }); +}); From 062fab0f0213db0fa3c986e94ad2632dbd733205 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Thu, 25 Jun 2026 13:22:15 -0300 Subject: [PATCH 2/2] chore(repo): add changeset --- .changeset/old-dancers-judge.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/old-dancers-judge.md diff --git a/.changeset/old-dancers-judge.md b/.changeset/old-dancers-judge.md new file mode 100644 index 00000000000..463f26e2667 --- /dev/null +++ b/.changeset/old-dancers-judge.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Fix the self-serve SSO configuration wizard losing your place when organization data refetches mid-flow. After submitting a Configure step (for example saving an identity provider's metadata), a background refetch on the OrganizationProfile Security page could unmount the open ConfigureSSO wizard and re-render it on an earlier step. The wizard now stays on its current step while data loads in the background.