Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/mosaic-use-clerk-mutation.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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] });
});
});
3 changes: 3 additions & 0 deletions packages/shared/src/react/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
28 changes: 28 additions & 0 deletions packages/shared/src/react/hooks/invalidateCacheKeys.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
for (const key of [keys].flat()) {
await client.invalidateQueries({ queryKey: key.invalidationKey });
const [prefix, ...rest] = key.invalidationKey;
await client.invalidateQueries({ queryKey: [`${prefix}-inf`, ...rest] });
}
}
6 changes: 6 additions & 0 deletions packages/shared/src/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
98 changes: 98 additions & 0 deletions packages/shared/src/react/query/__tests__/useMutation.spec.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
123 changes: 123 additions & 0 deletions packages/shared/src/react/query/useMutation.ts
Original file line number Diff line number Diff line change
@@ -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<TData, TError, TVariables, TOnMutateResult> {
/**
* 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, K extends PropertyKey> = T extends unknown ? Omit<T, K> : 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<TData>`. Drop it distributively (preserving the union) so we can mirror RQ React: a
// fire-and-forget `mutate` plus a promise-returning `mutateAsync`.
> = DistributiveOmit<MutationObserverResult<TData, TError, TVariables, TOnMutateResult>, 'mutate'> & {
mutate: (variables: TVariables, options?: MutateOptions<TData, TError, TVariables, TOnMutateResult>) => void;
mutateAsync: (
variables: TVariables,
options?: MutateOptions<TData, TError, TVariables, TOnMutateResult>,
) => Promise<TData>;
};

function notLoadedError(): Error {
return new Error('useClerkMutation: the Clerk query client is not ready yet; mutate was called too early.');
}

export function useClerkMutation<TData = unknown, TError = DefaultError, TVariables = void, TOnMutateResult = unknown>(
options: UseClerkMutationOptions<TData, TError, TVariables, TOnMutateResult>,
): UseClerkMutationResult<TData, TError, TVariables, TOnMutateResult> {
const [client, isLoaded] = useClerkQueryClient();

const { invalidate, onSuccess, ...mutationOptions } = options;

const mergedOptions: MutationObserverOptions<TData, TError, TVariables, TOnMutateResult> = {
...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<TData, TError, TVariables, TOnMutateResult>(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<TData, TError, TVariables, TOnMutateResult>) => {
// 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),
};
}
22 changes: 20 additions & 2 deletions packages/swingset/src/stories/delete-organization.stories.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -11,5 +13,21 @@ export const meta: StoryMeta = {
};

export function Default() {
return <DeleteOrganization />;
// 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<void>(resolve => setTimeout(resolve, 800)),
},
});

return (
<DeleteOrganizationView
snapshot={snapshot}
send={send}
canSubmit={actor.can({ type: 'CONFIRM' })}
/>
);
}
Loading
Loading