From b21f13b43e13715b13f6145e8069c9a03f78db75 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 26 Jun 2026 17:15:16 -0400 Subject: [PATCH] feat(shared,ui): Add `useClerkMutation` to `@clerk/shared/react` for query-core-backed mutations --- .changeset/mosaic-use-clerk-mutation.md | 6 + AGENTS.md | 2 +- .../__tests__/invalidateCacheKeys.spec.ts | 54 ++++++++ packages/shared/src/react/hooks/index.ts | 3 + .../src/react/hooks/invalidateCacheKeys.ts | 28 ++++ packages/shared/src/react/index.ts | 6 + .../query/__tests__/useMutation.spec.tsx | 98 ++++++++++++++ .../shared/src/react/query/useMutation.ts | 123 ++++++++++++++++++ .../stories/delete-organization.stories.tsx | 22 +++- .../stories/leave-organization.stories.tsx | 22 +++- .../organization-profile-general.stories.tsx | 14 +- .../stories/organization-profile.stories.tsx | 8 +- .../mosaic/aio/organization-profile-view.tsx | 55 ++++++++ .../src/mosaic/aio/organization-profile.tsx | 43 +----- .../ui/src/mosaic/mock/organization-store.ts | 13 -- .../ui/src/mosaic/mock/use-organization.tsx | 67 ---------- .../organization-profile-general-view.tsx | 41 ++++++ .../panels/organization-profile-general.tsx | 23 +--- .../delete-organization-controller.test.tsx | 112 ++++++++++++++++ .../leave-organization-controller.test.tsx | 116 +++++++++++++++++ .../delete-organization-controller.tsx | 25 +++- .../leave-organization-controller.tsx | 31 ++++- references/mosaic-architecture.md | 34 ++++- 23 files changed, 792 insertions(+), 154 deletions(-) create mode 100644 .changeset/mosaic-use-clerk-mutation.md create mode 100644 packages/shared/src/react/hooks/__tests__/invalidateCacheKeys.spec.ts create mode 100644 packages/shared/src/react/hooks/invalidateCacheKeys.ts create mode 100644 packages/shared/src/react/query/__tests__/useMutation.spec.tsx create mode 100644 packages/shared/src/react/query/useMutation.ts create mode 100644 packages/ui/src/mosaic/aio/organization-profile-view.tsx delete mode 100644 packages/ui/src/mosaic/mock/organization-store.ts delete mode 100644 packages/ui/src/mosaic/mock/use-organization.tsx create mode 100644 packages/ui/src/mosaic/panels/organization-profile-general-view.tsx create mode 100644 packages/ui/src/mosaic/sections/__tests__/delete-organization-controller.test.tsx create mode 100644 packages/ui/src/mosaic/sections/__tests__/leave-organization-controller.test.tsx diff --git a/.changeset/mosaic-use-clerk-mutation.md b/.changeset/mosaic-use-clerk-mutation.md new file mode 100644 index 00000000000..625ee5a4ba9 --- /dev/null +++ b/.changeset/mosaic-use-clerk-mutation.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': minor +'@clerk/ui': patch +--- + +Add `useClerkMutation` to `@clerk/shared/react` for query-core-backed mutations with structural cache invalidation. Pass `mutationFn` plus an `invalidate` descriptor (or array) built from `createCacheKeys`, and on success the matching list queries are invalidated automatically (including their `-inf` infinite-query siblings) so you no longer hand-poke `.revalidate()`. Before the Clerk query client attaches, `mutate`/`mutateAsync` reject loudly instead of silently no-oping. Also exports the `invalidateCacheKeys` helper, `createCacheKeys`, and `STABLE_KEYS` so consumers can build the same keys the built-in list hooks use. diff --git a/AGENTS.md b/AGENTS.md index 6621be94baf..6f91f77b157 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,6 @@ Clerk's JavaScript SDK and library monorepo. ## References - For questions about theming, appearance customization, or the styled system, see `references/theming-architecture.md`. -- For the Mosaic design system (tokens, CVA utility, `MosaicProvider`, migration from existing system), see `references/mosaic-architecture.md`. +- For the Mosaic design system (tokens, CVA utility, `MosaicProvider`, migration from existing system, plus the flow/data architecture — machine/controller/view split, `useClerkMutation`, and cache invalidation), see `references/mosaic-architecture.md`. - For dev setup, testing, JSDoc/Typedoc, publishing, changesets, and commit conventions, see `docs/CONTRIBUTING.md`. - For working in the repo day to day (setup ordering and footguns, the package map, dev-loop recipes, and the breaking-change checklist), the `clerk-monorepo` Claude Code skill in `.claude/skills/clerk-monorepo/` restates these rules in actionable form. diff --git a/packages/shared/src/react/hooks/__tests__/invalidateCacheKeys.spec.ts b/packages/shared/src/react/hooks/__tests__/invalidateCacheKeys.spec.ts new file mode 100644 index 00000000000..adcfa5b2f5e --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/invalidateCacheKeys.spec.ts @@ -0,0 +1,54 @@ +import type { QueryClient } from '@tanstack/query-core'; +import { describe, expect, it, vi } from 'vitest'; + +import { STABLE_KEYS } from '../../stable-keys'; +import { createCacheKeys } from '../createCacheKeys'; +import { invalidateCacheKeys } from '../invalidateCacheKeys'; + +function createSpyClient() { + const invalidateQueries = vi.fn(() => Promise.resolve()); + // Only `invalidateQueries` is exercised by the helper. + const client = { invalidateQueries } as unknown as QueryClient; + return { client, invalidateQueries }; +} + +const membershipsKeys = createCacheKeys({ + stablePrefix: STABLE_KEYS.USER_MEMBERSHIPS_KEY, + authenticated: true, + tracked: { userId: 'user_1' }, + untracked: { args: undefined }, +}); + +const invitationsKeys = createCacheKeys({ + stablePrefix: STABLE_KEYS.USER_INVITATIONS_KEY, + authenticated: true, + tracked: { userId: 'user_1' }, + untracked: { args: undefined }, +}); + +describe('invalidateCacheKeys', () => { + it('invalidates the base invalidation key and its `-inf` sibling for a single descriptor', async () => { + const { client, invalidateQueries } = createSpyClient(); + + await invalidateCacheKeys(client, membershipsKeys); + + expect(invalidateQueries).toHaveBeenCalledTimes(2); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: membershipsKeys.invalidationKey }); + const [prefix, ...rest] = membershipsKeys.invalidationKey; + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: [`${prefix}-inf`, ...rest] }); + }); + + it('flattens and invalidates every descriptor when passed an array', async () => { + const { client, invalidateQueries } = createSpyClient(); + + await invalidateCacheKeys(client, [membershipsKeys, invitationsKeys]); + + expect(invalidateQueries).toHaveBeenCalledTimes(4); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: membershipsKeys.invalidationKey }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: invitationsKeys.invalidationKey }); + const [mPrefix, ...mRest] = membershipsKeys.invalidationKey; + const [iPrefix, ...iRest] = invitationsKeys.invalidationKey; + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: [`${mPrefix}-inf`, ...mRest] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: [`${iPrefix}-inf`, ...iRest] }); + }); +}); diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index 34b1f771e1b..7ffdd9b54c8 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -1,4 +1,7 @@ export { assertContextExists, createContextAndHook } from './createContextAndHook'; +export { createCacheKeys } from './createCacheKeys'; +export { invalidateCacheKeys } from './invalidateCacheKeys'; +export type { InvalidationDescriptor } from './invalidateCacheKeys'; export { useAPIKeys } from './useAPIKeys'; export { useOAuthConsent } from './useOAuthConsent'; export type { UseOAuthConsentParams, UseOAuthConsentReturn } from './useOAuthConsent.types'; diff --git a/packages/shared/src/react/hooks/invalidateCacheKeys.ts b/packages/shared/src/react/hooks/invalidateCacheKeys.ts new file mode 100644 index 00000000000..e883f3dad6d --- /dev/null +++ b/packages/shared/src/react/hooks/invalidateCacheKeys.ts @@ -0,0 +1,28 @@ +import type { QueryClient } from '@tanstack/query-core'; + +/** + * The subset of a `createCacheKeys` result needed to invalidate a resource's + * cached queries. The first element of `invalidationKey` is the stable prefix. + */ +export type InvalidationDescriptor = { + invalidationKey: readonly [string, ...unknown[]]; +}; + +/** + * Invalidates the cached queries for one or more resources. + * + * For each descriptor this invalidates both the base `invalidationKey` and its + * `-inf` sibling so paginated and infinite variants of the same resource refetch + * together. This mirrors the bridge in `usePagesOrInfinite` so legacy hooks and + * Mosaic mutations stay in sync on a single cache. + */ +export async function invalidateCacheKeys( + client: QueryClient, + keys: InvalidationDescriptor | InvalidationDescriptor[], +): Promise { + for (const key of [keys].flat()) { + await client.invalidateQueries({ queryKey: key.invalidationKey }); + const [prefix, ...rest] = key.invalidationKey; + await client.invalidateQueries({ queryKey: [`${prefix}-inf`, ...rest] }); + } +} diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index 41788b66de4..68f580d075f 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -25,3 +25,9 @@ export { __setClerkQueryClientForTest, getClerkQueryClient, } from './query/clerk-query-client'; + +export { useClerkMutation } from './query/useMutation'; +export type { UseClerkMutationOptions, UseClerkMutationResult } from './query/useMutation'; + +export { STABLE_KEYS } from './stable-keys'; +export type { ResourceCacheStableKey } from './stable-keys'; diff --git a/packages/shared/src/react/query/__tests__/useMutation.spec.tsx b/packages/shared/src/react/query/__tests__/useMutation.spec.tsx new file mode 100644 index 00000000000..30a36448e5a --- /dev/null +++ b/packages/shared/src/react/query/__tests__/useMutation.spec.tsx @@ -0,0 +1,98 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createMockClerk, createMockQueryClient } from '../../hooks/__tests__/mocks/clerk'; +import { createCacheKeys } from '../../hooks/createCacheKeys'; +import { STABLE_KEYS } from '../../stable-keys'; +import { __resetClerkQueryClientForTest, __setClerkQueryClientForTest } from '../clerk-query-client'; +import { useClerkMutation } from '../useMutation'; + +let activeClerk: any; + +vi.mock('../../contexts', () => ({ + useAssertWrappedByClerkProvider: () => {}, + useClerkInstanceContext: () => activeClerk, + useInitialStateContext: () => undefined, +})); + +const wrapper = ({ children }: { children: React.ReactNode }) => <>{children}; + +afterEach(() => { + vi.clearAllMocks(); + __resetClerkQueryClientForTest(); +}); + +describe('useClerkMutation - once the query client attaches', () => { + beforeEach(() => { + createMockQueryClient(); + activeClerk = createMockClerk({ queryClient: undefined }); + }); + + it('starts idle and transitions to success', async () => { + const mutationFn = vi.fn(async () => 'done'); + const { result } = renderHook(() => useClerkMutation({ mutationFn }), { wrapper }); + + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(false); + expect(result.current.status).toBe('idle'); + + await act(async () => { + await result.current.mutateAsync(undefined); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBe('done'); + expect(mutationFn).toHaveBeenCalledTimes(1); + }); + + it('mutateAsync rejects when the mutationFn throws', async () => { + const error = new Error('boom'); + const mutationFn = vi.fn(async () => { + throw error; + }); + const { result } = renderHook(() => useClerkMutation({ mutationFn }), { wrapper }); + + await expect(result.current.mutateAsync(undefined)).rejects.toThrow('boom'); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBe(error); + }); + + it('invalidates the provided cache keys (base + `-inf`) on success', async () => { + const { client } = createMockQueryClient(); + activeClerk = createMockClerk({ queryClient: { __tag: 'clerk-rq-client', client } }); + const invalidateSpy = vi.spyOn(client, 'invalidateQueries'); + + const invalidate = createCacheKeys({ + stablePrefix: STABLE_KEYS.USER_MEMBERSHIPS_KEY, + authenticated: true, + tracked: { userId: 'user_1' }, + untracked: { args: undefined }, + }); + + const { result } = renderHook(() => useClerkMutation({ mutationFn: async () => 'ok', invalidate }), { wrapper }); + + await act(async () => { + await result.current.mutateAsync(undefined); + }); + + await waitFor(() => expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: invalidate.invalidationKey })); + const [prefix, ...rest] = invalidate.invalidationKey; + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: [`${prefix}-inf`, ...rest] }); + }); +}); + +describe('useClerkMutation - before the query client attaches', () => { + beforeEach(() => { + activeClerk = createMockClerk({ queryClient: null }); + __setClerkQueryClientForTest(undefined); + }); + + it('rejects loudly instead of silently succeeding', async () => { + const mutationFn = vi.fn(async () => 'should not run'); + const { result } = renderHook(() => useClerkMutation({ mutationFn }), { wrapper }); + + await expect(result.current.mutateAsync(undefined)).rejects.toThrow(); + expect(mutationFn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/react/query/useMutation.ts b/packages/shared/src/react/query/useMutation.ts new file mode 100644 index 00000000000..ffde309f18b --- /dev/null +++ b/packages/shared/src/react/query/useMutation.ts @@ -0,0 +1,123 @@ +/** + * Stripped-down `useMutation` over `@tanstack/query-core`'s `MutationObserver`, + * mirroring `useBaseQuery`: the observer is recreated whenever the Clerk query + * client changes, and a single hook covers the only mutation observer type (no + * separate base hook to wrap). + */ + +'use client'; +import type { + DefaultError, + MutateOptions, + MutationFunctionContext, + MutationObserverOptions, + MutationObserverResult, +} from '@tanstack/query-core'; +import { MutationObserver, notifyManager } from '@tanstack/query-core'; +import * as React from 'react'; + +import type { InvalidationDescriptor } from '../hooks/invalidateCacheKeys'; +import { invalidateCacheKeys } from '../hooks/invalidateCacheKeys'; +import { useClerkQueryClient } from './use-clerk-query-client'; + +export interface UseClerkMutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> extends MutationObserverOptions { + /** + * Cache key descriptor(s) (from `createCacheKeys`) for the resources whose + * lists change when this mutation succeeds. They are invalidated structurally + * (base + `-inf` sibling) after the mutation resolves, before `mutateAsync` + * settles, so callers never hand-poke `.revalidate()`. + */ + invalidate?: InvalidationDescriptor | InvalidationDescriptor[]; +} + +type DistributiveOmit = T extends unknown ? Omit : never; + +export type UseClerkMutationResult< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, + // `MutationObserverResult` is a discriminated union and already declares a `mutate` that returns + // `Promise`. Drop it distributively (preserving the union) so we can mirror RQ React: a + // fire-and-forget `mutate` plus a promise-returning `mutateAsync`. +> = DistributiveOmit, 'mutate'> & { + mutate: (variables: TVariables, options?: MutateOptions) => void; + mutateAsync: ( + variables: TVariables, + options?: MutateOptions, + ) => Promise; +}; + +function notLoadedError(): Error { + return new Error('useClerkMutation: the Clerk query client is not ready yet; mutate was called too early.'); +} + +export function useClerkMutation( + options: UseClerkMutationOptions, +): UseClerkMutationResult { + const [client, isLoaded] = useClerkQueryClient(); + + const { invalidate, onSuccess, ...mutationOptions } = options; + + const mergedOptions: MutationObserverOptions = { + ...mutationOptions, + onSuccess: async ( + data: TData, + variables: TVariables, + onMutateResult: TOnMutateResult, + context: MutationFunctionContext, + ) => { + if (invalidate) { + await invalidateCacheKeys(client, invalidate); + } + return onSuccess?.(data, variables, onMutateResult, context); + }, + }; + + const observer = React.useMemo( + () => new MutationObserver(client, mergedOptions), + // Recreate the observer only when the client changes; option updates flow through setOptions below. + // eslint-disable-next-line react-hooks/exhaustive-deps + [client], + ); + + React.useEffect(() => { + observer.setOptions(mergedOptions); + }); + + const result = React.useSyncExternalStore( + React.useCallback(onStoreChange => observer.subscribe(notifyManager.batchCalls(onStoreChange)), [observer]), + () => observer.getCurrentResult(), + () => observer.getCurrentResult(), + ); + + const mutate = React.useCallback( + (variables: TVariables, mutateOptions?: MutateOptions) => { + // Errors surface through the observer result; mirror RQ React and swallow the promise rejection. + observer.mutate(variables, mutateOptions).catch(() => {}); + }, + [observer], + ); + + if (!isLoaded) { + // Decision: before the query client attaches, mutating must fail loudly rather than silently no-op. + return { + ...result, + mutate: () => { + throw notLoadedError(); + }, + mutateAsync: () => Promise.reject(notLoadedError()), + }; + } + + return { + ...result, + mutate, + mutateAsync: (variables, mutateOptions) => observer.mutate(variables, mutateOptions), + }; +} diff --git a/packages/swingset/src/stories/delete-organization.stories.tsx b/packages/swingset/src/stories/delete-organization.stories.tsx index d7b1e435222..2340c23cf5d 100644 --- a/packages/swingset/src/stories/delete-organization.stories.tsx +++ b/packages/swingset/src/stories/delete-organization.stories.tsx @@ -1,5 +1,7 @@ /** @jsxImportSource @emotion/react */ -import { DeleteOrganization } from '@clerk/ui/mosaic/sections/delete-organization'; +import { useMachine } from '@clerk/ui/mosaic/machine/useMachine'; +import { deleteOrgMachine } from '@clerk/ui/mosaic/sections/delete-organization-machine'; +import { DeleteOrganizationView } from '@clerk/ui/mosaic/sections/delete-organization-view'; import type { StoryMeta } from '@/lib/types'; @@ -11,5 +13,21 @@ export const meta: StoryMeta = { }; export function Default() { - return ; + // Swingset has no Clerk context, so the story drives the presentational view with a + // mock machine instead of the Clerk-backed controller. The flow (open, confirm, + // deleting, deleted) is identical; only the resolved mutation is faked. + const [snapshot, send, actor] = useMachine(deleteOrgMachine, { + context: { + organizationName: 'Acme Inc', + destroyOrganization: () => new Promise(resolve => setTimeout(resolve, 800)), + }, + }); + + return ( + + ); } diff --git a/packages/swingset/src/stories/leave-organization.stories.tsx b/packages/swingset/src/stories/leave-organization.stories.tsx index 3ec1c554292..865ee6a7d81 100644 --- a/packages/swingset/src/stories/leave-organization.stories.tsx +++ b/packages/swingset/src/stories/leave-organization.stories.tsx @@ -1,5 +1,7 @@ /** @jsxImportSource @emotion/react */ -import { LeaveOrganization } from '@clerk/ui/mosaic/sections/leave-organization'; +import { useMachine } from '@clerk/ui/mosaic/machine/useMachine'; +import { leaveOrgMachine } from '@clerk/ui/mosaic/sections/leave-organization-machine'; +import { LeaveOrganizationView } from '@clerk/ui/mosaic/sections/leave-organization-view'; import type { StoryMeta } from '@/lib/types'; @@ -11,5 +13,21 @@ export const meta: StoryMeta = { }; export function Default() { - return ; + // Swingset has no Clerk context, so the story drives the presentational view with a + // mock machine instead of the Clerk-backed controller. The flow (open, confirm, + // leaving, left) is identical; only the resolved mutation is faked. + const [snapshot, send, actor] = useMachine(leaveOrgMachine, { + context: { + organizationName: 'Acme Inc', + leaveOrganization: () => new Promise(resolve => setTimeout(resolve, 800)), + }, + }); + + return ( + + ); } diff --git a/packages/swingset/src/stories/organization-profile-general.stories.tsx b/packages/swingset/src/stories/organization-profile-general.stories.tsx index b969053afa7..976b217676e 100644 --- a/packages/swingset/src/stories/organization-profile-general.stories.tsx +++ b/packages/swingset/src/stories/organization-profile-general.stories.tsx @@ -1,8 +1,11 @@ /** @jsxImportSource @emotion/react */ -import { OrganizationProfileGeneral } from '@clerk/ui/mosaic/panels/organization-profile-general'; +import { OrganizationProfileGeneralView } from '@clerk/ui/mosaic/panels/organization-profile-general-view'; import type { StoryMeta } from '@/lib/types'; +import { Default as DeleteOrganizationDemo } from './delete-organization.stories'; +import { Default as LeaveOrganizationDemo } from './leave-organization.stories'; + export const meta: StoryMeta = { group: 'Panels', title: 'OrganizationProfileGeneral', @@ -11,5 +14,12 @@ export const meta: StoryMeta = { }; export function Default() { - return ; + // swingset renders the presentational shell with the view-only section previews as + // slots. Production composes the same shell with the Clerk-connected sections. + return ( + } + deleteOrganization={} + /> + ); } diff --git a/packages/swingset/src/stories/organization-profile.stories.tsx b/packages/swingset/src/stories/organization-profile.stories.tsx index e609e3854f8..43034d89b28 100644 --- a/packages/swingset/src/stories/organization-profile.stories.tsx +++ b/packages/swingset/src/stories/organization-profile.stories.tsx @@ -1,8 +1,10 @@ /** @jsxImportSource @emotion/react */ -import { OrganizationProfile } from '@clerk/ui/mosaic/aio/organization-profile'; +import { OrganizationProfileView } from '@clerk/ui/mosaic/aio/organization-profile-view'; import type { StoryMeta } from '@/lib/types'; +import { Default as OrganizationProfileGeneralDemo } from './organization-profile-general.stories'; + export const meta: StoryMeta = { group: 'AIO', title: 'OrganizationProfile', @@ -11,5 +13,7 @@ export const meta: StoryMeta = { }; export function Default() { - return ; + // swingset renders the presentational shell with the view-only General panel preview + // as a slot. Production composes the same shell with the Clerk-connected panel. + return } />; } diff --git a/packages/ui/src/mosaic/aio/organization-profile-view.tsx b/packages/ui/src/mosaic/aio/organization-profile-view.tsx new file mode 100644 index 00000000000..b7a97809be3 --- /dev/null +++ b/packages/ui/src/mosaic/aio/organization-profile-view.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from 'react'; + +import { Box } from '../components/box'; +import { Tabs } from '../components/tabs'; + +interface OrganizationProfileViewProps { + /** The General tab's panel content. */ + general: ReactNode; +} + +/** + * Presentational layout for the Organization Profile flow: the heading and tab shell. + * It is Clerk-free; the General panel is passed in as a slot. The connected + * `OrganizationProfile` injects the Clerk-backed panel; swingset injects the view-only + * preview. + */ +export function OrganizationProfileView({ general }: OrganizationProfileViewProps) { + return ( + ({ + width: '100%', + })} + > +

} + sx={t => ({ + ...t.text('lg'), + fontWeight: t.font.semibold, + marginBlockEnd: t.spacing(8), + })} + > + Organization Profile + + + + General + Members + + {general} + +

} + sx={t => ({ + ...t.text('base'), + fontWeight: t.font.medium, + textAlign: 'center', + })} + > + Members content + + + + + ); +} diff --git a/packages/ui/src/mosaic/aio/organization-profile.tsx b/packages/ui/src/mosaic/aio/organization-profile.tsx index 743da8386b5..01f59620361 100644 --- a/packages/ui/src/mosaic/aio/organization-profile.tsx +++ b/packages/ui/src/mosaic/aio/organization-profile.tsx @@ -1,45 +1,6 @@ -import { Box } from '../components/box'; -import { Tabs } from '../components/tabs'; import { OrganizationProfileGeneral } from '../panels/organization-profile-general'; +import { OrganizationProfileView } from './organization-profile-view'; export function OrganizationProfile() { - return ( - ({ - width: '100%', - })} - > -

} - sx={t => ({ - ...t.text('lg'), - fontWeight: t.font.semibold, - marginBlockEnd: t.spacing(8), - })} - > - Organization Profile - - - - General - Members - - - - - -

} - sx={t => ({ - ...t.text('base'), - fontWeight: t.font.medium, - textAlign: 'center', - })} - > - Members content - - - - - ); + return } />; } diff --git a/packages/ui/src/mosaic/mock/organization-store.ts b/packages/ui/src/mosaic/mock/organization-store.ts deleted file mode 100644 index 3a1fb367992..00000000000 --- a/packages/ui/src/mosaic/mock/organization-store.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Mock timing constants for the org-profile prototype. - * - * Simulated async only — no real SDK. The loading state itself lives in local - * component state in `useOrganization()`. - */ - -/** Time until the simulated org load resolves. */ -export const LOAD_DELAY_MS = 600; -/** Artificial latency for `destroy()` mutations. */ -export const MUTATION_DELAY_MS = 2000; - -export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); diff --git a/packages/ui/src/mosaic/mock/use-organization.tsx b/packages/ui/src/mosaic/mock/use-organization.tsx deleted file mode 100644 index 37da1a4daf3..00000000000 --- a/packages/ui/src/mosaic/mock/use-organization.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { delay, LOAD_DELAY_MS, MUTATION_DELAY_MS } from './organization-store'; - -/** - * Mock `useOrganization` for prototyping Mosaic organization-profile components. - * - * Mirrors the shape of the real hook in - * `packages/shared/src/react/hooks/useOrganization.tsx`: a - * `{ isLoaded, organization, membership }` discriminated union where - * `organization.destroy()` deletes the org and `membership.destroy()` leaves it. - * - * Loading state is held in local component state and flips to "loaded" after a - * simulated delay. Simulated async only — no real SDK. - */ - -export interface MockOrganization { - id: string; - name: string; - slug: string | null; - membersCount: number; - /** Delete the entire organization (admin-only in the real API). */ - destroy: () => Promise; -} - -export interface MockMembership { - id: string; - /** e.g. 'org:admin' | 'org:member' */ - role: string; - /** Leave the organization (removes the current member). */ - destroy: () => Promise; -} - -// Mirrors the real discriminated union: while loading, every field is `undefined`. -export type UseOrganizationReturn = - | { isLoaded: false; organization: undefined; membership: undefined } - | { isLoaded: true; organization: MockOrganization | null; membership: MockMembership | null }; - -export function useOrganization(): UseOrganizationReturn { - // Starts `false` so SSR markup matches the first client render, then flips after the delay. - const [isLoaded, setIsLoaded] = useState(false); - - useEffect(() => { - const timer = setTimeout(() => setIsLoaded(true), LOAD_DELAY_MS); - return () => clearTimeout(timer); - }, []); - - if (!isLoaded) { - return { isLoaded: false, organization: undefined, membership: undefined }; - } - - return { - isLoaded: true, - organization: { - id: 'org_mock', - name: "Alex's Organization", - slug: 'alex-org', - membersCount: 4, - destroy: () => delay(MUTATION_DELAY_MS), - }, - membership: { - id: 'mem_mock', - role: 'org:admin', - destroy: () => delay(MUTATION_DELAY_MS), - }, - }; -} diff --git a/packages/ui/src/mosaic/panels/organization-profile-general-view.tsx b/packages/ui/src/mosaic/panels/organization-profile-general-view.tsx new file mode 100644 index 00000000000..42486e7616d --- /dev/null +++ b/packages/ui/src/mosaic/panels/organization-profile-general-view.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react'; + +import { Box } from '../components/box'; +import { alpha } from '../utils'; + +interface OrganizationProfileGeneralViewProps { + /** The leave-organization section, rendered above the divider. */ + leaveOrganization: ReactNode; + /** The delete-organization section, rendered below the divider. */ + deleteOrganization: ReactNode; +} + +/** + * Presentational layout for the General panel. It owns the arrangement of the two + * sections (and the divider between them) but is Clerk-free: the sections are passed + * in as slots. The connected `OrganizationProfileGeneral` injects the Clerk-backed + * sections; swingset injects the view-only previews. + */ +export function OrganizationProfileGeneralView({ + leaveOrganization, + deleteOrganization, +}: OrganizationProfileGeneralViewProps) { + return ( + ({ + width: '100%', + containerType: 'inline-size', + })} + > + {leaveOrganization} + ({ + height: '1px', + background: `light-dark(${alpha('#000', 10)},${alpha('#fff', 10)})`, + marginBlock: t.spacing(4), + })} + /> + {deleteOrganization} + + ); +} diff --git a/packages/ui/src/mosaic/panels/organization-profile-general.tsx b/packages/ui/src/mosaic/panels/organization-profile-general.tsx index fc346ff9f75..7aca4421094 100644 --- a/packages/ui/src/mosaic/panels/organization-profile-general.tsx +++ b/packages/ui/src/mosaic/panels/organization-profile-general.tsx @@ -1,25 +1,12 @@ -import { Box } from '../components/box'; import { DeleteOrganization } from '../sections/delete-organization'; import { LeaveOrganization } from '../sections/leave-organization'; -import { alpha } from '../utils'; +import { OrganizationProfileGeneralView } from './organization-profile-general-view'; export function OrganizationProfileGeneral() { return ( - ({ - width: '100%', - containerType: 'inline-size', - })} - > - - ({ - height: '1px', - background: `light-dark(${alpha('#000', 10)},${alpha('#fff', 10)})`, - marginBlock: t.spacing(4), - })} - /> - - + } + deleteOrganization={} + /> ); } diff --git a/packages/ui/src/mosaic/sections/__tests__/delete-organization-controller.test.tsx b/packages/ui/src/mosaic/sections/__tests__/delete-organization-controller.test.tsx new file mode 100644 index 00000000000..8356956e55a --- /dev/null +++ b/packages/ui/src/mosaic/sections/__tests__/delete-organization-controller.test.tsx @@ -0,0 +1,112 @@ +import { __createClerkTestQueryClient, __resetClerkQueryClientForTest, STABLE_KEYS } from '@clerk/shared/react'; +import type { QueryClient } from '@tanstack/query-core'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useDeleteOrganizationController } from '../delete-organization-controller'; + +/** A promise whose resolution/rejection the test controls. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const ORG_NAME = 'Acme Inc'; + +let destroy: ReturnType; +let organization: { id: string; name: string; destroy: () => Promise } | null; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useOrganization: () => ({ isLoaded: true, organization, membership: null }), + useUser: () => ({ isLoaded: true, isSignedIn: true, user: { id: 'user_1' } }), + }; +}); + +let client: QueryClient; +let invalidateSpy: ReturnType; + +beforeEach(() => { + client = __createClerkTestQueryClient(); + invalidateSpy = vi.spyOn(client, 'invalidateQueries'); + destroy = vi.fn(); + organization = { id: 'org_1', name: ORG_NAME, destroy }; +}); + +afterEach(() => { + vi.clearAllMocks(); + __resetClerkQueryClientForTest(); +}); + +function Harness() { + const controller = useDeleteOrganizationController(); + if (controller.status !== 'ready') { + return loading; + } + return ( +
+ {controller.snapshot.value} + {controller.snapshot.context.error ?? ''} + + + +
+ ); +} + +function openAndConfirm() { + fireEvent.click(screen.getByText('Open')); + fireEvent.click(screen.getByText('Type')); + fireEvent.click(screen.getByText('Confirm')); +} + +describe('useDeleteOrganizationController', () => { + it('drives CONFIRM → deleting → resolve → deleted and invalidates memberships', async () => { + const gate = deferred(); + destroy.mockReturnValue(gate.promise); + + render(); + expect(screen.getByTestId('state')).toHaveTextContent('idle'); + + openAndConfirm(); + expect(screen.getByTestId('state')).toHaveTextContent('deleting'); + + await act(async () => { + gate.resolve(); + }); + + expect(screen.getByTestId('state')).toHaveTextContent('deleted'); + expect(destroy).toHaveBeenCalledTimes(1); + + const membershipsKey = [STABLE_KEYS.USER_MEMBERSHIPS_KEY, true, { userId: 'user_1' }]; + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: membershipsKey }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: [`${STABLE_KEYS.USER_MEMBERSHIPS_KEY}-inf`, true, { userId: 'user_1' }], + }); + }); + + it('returns to confirming with an error message when deleting rejects', async () => { + const gate = deferred(); + destroy.mockReturnValue(gate.promise); + + render(); + openAndConfirm(); + expect(screen.getByTestId('state')).toHaveTextContent('deleting'); + + await act(async () => { + gate.reject(new Error('nope')); + }); + + expect(screen.getByTestId('state')).toHaveTextContent('confirming'); + expect(screen.getByTestId('error')).toHaveTextContent('nope'); + expect(invalidateSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/mosaic/sections/__tests__/leave-organization-controller.test.tsx b/packages/ui/src/mosaic/sections/__tests__/leave-organization-controller.test.tsx new file mode 100644 index 00000000000..cf851eec875 --- /dev/null +++ b/packages/ui/src/mosaic/sections/__tests__/leave-organization-controller.test.tsx @@ -0,0 +1,116 @@ +import { __createClerkTestQueryClient, __resetClerkQueryClientForTest, STABLE_KEYS } from '@clerk/shared/react'; +import type { QueryClient } from '@tanstack/query-core'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useLeaveOrganizationController } from '../leave-organization-controller'; + +/** A promise whose resolution/rejection the test controls. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const ORG_NAME = 'Acme Inc'; + +let destroy: ReturnType; +let organization: { id: string; name: string } | null; +let membership: { id: string; destroy: () => Promise } | null; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useOrganization: () => ({ isLoaded: true, organization, membership }), + useUser: () => ({ isLoaded: true, isSignedIn: true, user: { id: 'user_1' } }), + }; +}); + +let client: QueryClient; +let invalidateSpy: ReturnType; + +beforeEach(() => { + client = __createClerkTestQueryClient(); + invalidateSpy = vi.spyOn(client, 'invalidateQueries'); + destroy = vi.fn(); + organization = { id: 'org_1', name: ORG_NAME }; + membership = { id: 'mem_1', destroy }; +}); + +afterEach(() => { + vi.clearAllMocks(); + __resetClerkQueryClientForTest(); +}); + +function Harness() { + const controller = useLeaveOrganizationController(); + if (controller.status !== 'ready') { + return loading; + } + return ( +
+ {controller.snapshot.value} + {controller.snapshot.context.error ?? ''} + + + +
+ ); +} + +function openAndConfirm() { + fireEvent.click(screen.getByText('Open')); + fireEvent.click(screen.getByText('Type')); + fireEvent.click(screen.getByText('Confirm')); +} + +describe('useLeaveOrganizationController', () => { + it('drives CONFIRM → leaving → resolve → left and invalidates the lists that change', async () => { + const gate = deferred(); + destroy.mockReturnValue(gate.promise); + + render(); + expect(screen.getByTestId('state')).toHaveTextContent('idle'); + + openAndConfirm(); + expect(screen.getByTestId('state')).toHaveTextContent('leaving'); + + await act(async () => { + gate.resolve(); + }); + + expect(screen.getByTestId('state')).toHaveTextContent('left'); + expect(destroy).toHaveBeenCalledTimes(1); + + const membershipsKey = [STABLE_KEYS.USER_MEMBERSHIPS_KEY, true, { userId: 'user_1' }]; + const invitationsKey = [STABLE_KEYS.USER_INVITATIONS_KEY, true, { userId: 'user_1' }]; + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: membershipsKey }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: invitationsKey }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: [`${STABLE_KEYS.USER_MEMBERSHIPS_KEY}-inf`, true, { userId: 'user_1' }], + }); + }); + + it('returns to confirming with an error message when leaving rejects', async () => { + const gate = deferred(); + destroy.mockReturnValue(gate.promise); + + render(); + openAndConfirm(); + expect(screen.getByTestId('state')).toHaveTextContent('leaving'); + + await act(async () => { + gate.reject(new Error('nope')); + }); + + expect(screen.getByTestId('state')).toHaveTextContent('confirming'); + expect(screen.getByTestId('error')).toHaveTextContent('nope'); + expect(invalidateSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/mosaic/sections/delete-organization-controller.tsx b/packages/ui/src/mosaic/sections/delete-organization-controller.tsx index d46bd67fe89..c6c6322683e 100644 --- a/packages/ui/src/mosaic/sections/delete-organization-controller.tsx +++ b/packages/ui/src/mosaic/sections/delete-organization-controller.tsx @@ -1,13 +1,34 @@ +import { createCacheKeys, STABLE_KEYS, useClerkMutation, useOrganization, useUser } from '@clerk/shared/react'; + import { useMachine } from '../machine/useMachine'; -import { useOrganization } from '../mock/use-organization'; import { deleteOrgMachine } from './delete-organization-machine'; export function useDeleteOrganizationController() { const { isLoaded, organization } = useOrganization(); + const { user } = useUser(); + + // Deleting an org changes the user's memberships list — same prefix `useOrganizationList` writes. + const invalidate = [ + createCacheKeys({ + stablePrefix: STABLE_KEYS.USER_MEMBERSHIPS_KEY, + authenticated: true, + tracked: { userId: user?.id }, + untracked: { args: undefined }, + }), + ]; + + const del = useClerkMutation({ + mutationFn: async () => { + await organization?.destroy(); + }, + invalidate, + }); + const [snapshot, send, actor] = useMachine(deleteOrgMachine, { context: { organizationName: organization?.name ?? '', - destroyOrganization: () => organization?.destroy() ?? Promise.resolve(), + // The machine owns pending/error state; the mutation does the call + cache invalidation. + destroyOrganization: () => del.mutateAsync(undefined), }, }); diff --git a/packages/ui/src/mosaic/sections/leave-organization-controller.tsx b/packages/ui/src/mosaic/sections/leave-organization-controller.tsx index 7f10440c7b7..ce2c6a2668c 100644 --- a/packages/ui/src/mosaic/sections/leave-organization-controller.tsx +++ b/packages/ui/src/mosaic/sections/leave-organization-controller.tsx @@ -1,13 +1,40 @@ +import { createCacheKeys, STABLE_KEYS, useClerkMutation, useOrganization, useUser } from '@clerk/shared/react'; + import { useMachine } from '../machine/useMachine'; -import { useOrganization } from '../mock/use-organization'; import { leaveOrgMachine } from './leave-organization-machine'; export function useLeaveOrganizationController() { const { isLoaded, organization, membership } = useOrganization(); + const { user } = useUser(); + + // The lists that change when you leave an org — same prefixes `useOrganizationList` writes. + const invalidate = [ + createCacheKeys({ + stablePrefix: STABLE_KEYS.USER_MEMBERSHIPS_KEY, + authenticated: true, + tracked: { userId: user?.id }, + untracked: { args: undefined }, + }), + createCacheKeys({ + stablePrefix: STABLE_KEYS.USER_INVITATIONS_KEY, + authenticated: true, + tracked: { userId: user?.id }, + untracked: { args: undefined }, + }), + ]; + + const leave = useClerkMutation({ + mutationFn: async () => { + await membership?.destroy(); + }, + invalidate, + }); + const [snapshot, send, actor] = useMachine(leaveOrgMachine, { context: { organizationName: organization?.name ?? '', - leaveOrganization: () => membership?.destroy() ?? Promise.resolve(), + // The machine owns pending/error state; the mutation does the call + cache invalidation. + leaveOrganization: () => leave.mutateAsync(undefined), }, }); diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md index 102fcaef793..76001d8d0d7 100644 --- a/references/mosaic-architecture.md +++ b/references/mosaic-architecture.md @@ -431,15 +431,35 @@ Machine tests should use `createActor()` directly. They should not render React ### Controllers -Controllers are the adapter from Clerk resources into machine context and view props. They may call hooks like `useOrganization()` and inject live resource methods: +Controllers are the adapter from Clerk resources into machine context and view props. They read Clerk hooks (`useOrganization`, `useUser`), build a `useClerkMutation` for the write, and inject `mutateAsync` as the machine's async effect: ```tsx export function useDeleteOrganizationController() { const { isLoaded, organization } = useOrganization(); + const { user } = useUser(); + + // Same prefix `useOrganizationList` writes — invalidated after the delete so the list refetches. + const invalidate = [ + createCacheKeys({ + stablePrefix: STABLE_KEYS.USER_MEMBERSHIPS_KEY, + authenticated: true, + tracked: { userId: user?.id }, + untracked: { args: undefined }, + }), + ]; + + const del = useClerkMutation({ + mutationFn: async () => { + await organization?.destroy(); + }, + invalidate, + }); + const [snapshot, send, actor] = useMachine(deleteOrgMachine, { context: { organizationName: organization?.name ?? '', - destroyOrganization: () => organization?.destroy() ?? Promise.resolve(), + // The machine owns pending/error state; the mutation does the call + cache invalidation. + destroyOrganization: () => del.mutateAsync(undefined), }, }); @@ -458,6 +478,16 @@ export function useDeleteOrganizationController() { Controllers should pass plain data and plain functions into machines. Do not pass Clerk resource objects through to views. +### Mutations and cache invalidation + +Reads use the standard Clerk hooks (`useOrganization`, `useUser`, `useOrganizationList`) — including their paginated lists. Writes go through `useClerkMutation` (`@clerk/shared/react`), a thin wrapper over query-core's `MutationObserver` on the same cache those read hooks populate. The point of the wrapper is the **invalidation contract**, so a write refetches the lists it changed without anyone hand-poking `.revalidate()`: + +- `invalidate` takes `InvalidationDescriptor`(s) from `createCacheKeys`, keyed by a `STABLE_KEYS` prefix — the same prefix the matching read hook writes under. After the mutation succeeds (and before `mutateAsync` settles), each descriptor's `invalidationKey` **and its `-inf` sibling** are invalidated, so paginated and infinite variants refetch together. +- `mutate` is fire-and-forget; `mutateAsync` returns the promise a machine's `fromPromise` awaits. +- Before the query client attaches, `mutate`/`mutateAsync` fail loudly rather than silently no-op. + +Pending/error ownership: in a machine-backed flow the **machine** owns pending/error (the controller injects `mutateAsync` and ignores the hook's `isPending`). A plain single-step mutation with no machine lets `useClerkMutation` own them directly. Never both at once. + ### Views Views render snapshots and emit events. They receive any derived booleans from the controller, including `actor.can(...)` results, so they do not duplicate machine guards: