Skip to content

Commit 9f0874e

Browse files
fix(mosaic): suppress stale invitation success UI (#10551)
1 parent 12b53e6 commit 9f0874e

17 files changed

Lines changed: 321 additions & 53 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/mosaic': patch
3+
---
4+
5+
Suppress organization invitation success notifications when the members panel closes or the active organization or session changes before the invitation finishes.

‎packages/mosaic/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Most problems a feature hits already have a shared answer. Find the row for your
3434
| Problem | Use |
3535
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
3636
| An async action from a button or row: pending, an error, one lock | `usePendingAction` (`src/hooks/`) |
37-
| A form: values, validation, submit, field errors | `useForm` (`src/components/form/`), with `validateAsync` for a server-side check |
37+
| A form: values, validation, submit, field errors, success UI | `useForm` (`src/components/form/`), with `validateAsync` for server checks and `onSuccess` for mounted success UI |
3838
| A `<form>` element, without the browser's validation popup | `Form` (`src/primitives/form/`) |
3939
| A model save whose Clerk errors should land on form fields | `save` (`src/utils/errors.ts`) in the model, or reject with `FormSubmitError` |
4040
| A confirm step before an action | `useConfirmController` (`src/blocks/confirm/`) with `Confirmation` (`src/blocks/confirmation/`) |

‎packages/mosaic/ARCHITECTURE.md‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -457,10 +457,8 @@ export function useUserProfileEditUsernameController({ username = '', required =
457457
const form = useForm({
458458
initialValues: { username },
459459
canSubmit: values => values.username !== username && (!required || values.username !== ''),
460-
onSubmit: async values => {
461-
await onSubmit(values.username);
462-
setIsOpen(false);
463-
},
460+
onSubmit: values => onSubmit(values.username),
461+
onSuccess: () => setIsOpen(false),
464462
});
465463

466464
const onOpenChange = (open: boolean) => {

‎packages/mosaic/docs/controllers.md‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,26 @@ boolean spelled long. `useUserProfileEditUsernameController` holds `isOpen` in
121121
const [isOpen, setIsOpen] = useState(false);
122122
const form = useForm({
123123
initialValues: { username },
124-
onSubmit: async values => {
125-
await onSubmit(values.username);
126-
setIsOpen(false);
127-
},
124+
onSubmit: values => onSubmit(values.username),
125+
onSuccess: () => setIsOpen(false),
128126
});
129127
```
130128

129+
Keep mutations and required revalidation in `onSubmit`. Put success UI, such as
130+
closing the dialog or showing a toast, in the optional synchronous
131+
`onSuccess(result, submittedValues)` callback. The result type comes from
132+
`onSubmit`. The callback and values belong to the submission that started the
133+
request. The form machine delivers success only while its actor is mounted.
134+
Rejection and cancelled reverification skip the callback.
135+
136+
The callback runs during the completion transition, before `isSubmitting` clears.
137+
Use it to close or display UI, not to reset or resubmit the form. A resolved
138+
`onSubmit` counts as success even if it returned early. Prevent no-op submissions
139+
with `canSubmit`, or return a result that lets `onSuccess` distinguish them.
140+
Unmount suppression does not cancel the underlying request. Identity changes
141+
must unmount the form, for example through the composing component's React key,
142+
if they should discard its completion UI.
143+
131144
The choice is invisible from outside — the controller returns plain props either
132145
way, so the view and its tests are unaffected, and swapping one for the other
133146
later is a change to one file. Don't front-load a machine for a flow that has

‎packages/mosaic/src/components/form/__tests__/use-form.test.ts‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { ClerkRuntimeError } from '@clerk/shared/error';
22
import { act, renderHook } from '@testing-library/react';
3+
import { createElement, StrictMode, useEffect, useLayoutEffect } from 'react';
4+
import { flushSync } from 'react-dom';
35
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
46

57
import { deferred, tick } from '../../../__tests__/async';
@@ -66,6 +68,131 @@ describe('useForm', () => {
6668
expect(result.current.error).toBeUndefined();
6769
});
6870

71+
it('reports the exact result and submitted values once using the callback captured at submit', async () => {
72+
const request = deferred<{ id: string }>();
73+
const onSuccess = vi.fn();
74+
const replacement = vi.fn();
75+
const { result, rerender } = renderHook(
76+
({ onSuccess }) => useForm({ initialValues: { username: 'alex' }, onSubmit: () => request.promise, onSuccess }),
77+
{ initialProps: { onSuccess } },
78+
);
79+
act(() => result.current.setValue('username', 'alexc'));
80+
const submittedValues = result.current.values;
81+
act(() => result.current.submit());
82+
act(() => result.current.submit());
83+
rerender({ onSuccess: replacement });
84+
const saved = { id: 'saved' };
85+
await act(async () => {
86+
request.resolve(saved);
87+
await tick();
88+
});
89+
expect(onSuccess).toHaveBeenCalledExactlyOnceWith(saved, submittedValues);
90+
expect(onSuccess.mock.calls[0]?.[0]).toBe(saved);
91+
expect(onSuccess.mock.calls[0]?.[1]).toBe(submittedValues);
92+
expect(replacement).not.toHaveBeenCalled();
93+
expect(result.current.isSubmitting).toBe(false);
94+
});
95+
96+
it.each([
97+
new FormSubmitError({ message: 'Could not save' }),
98+
new ClerkRuntimeError('User cancelled attempted verification', { code: 'reverification_cancelled' }),
99+
])('does not report success when submission rejects with %s', async failure => {
100+
const onSuccess = vi.fn();
101+
const { result } = renderHook(() =>
102+
useForm({
103+
initialValues: { username: 'alex' },
104+
onSubmit: () => Promise.reject(failure),
105+
onSuccess,
106+
}),
107+
);
108+
await act(async () => {
109+
result.current.submit();
110+
await tick();
111+
});
112+
expect(onSuccess).not.toHaveBeenCalled();
113+
expect(result.current.isSubmitting).toBe(false);
114+
});
115+
116+
it('does not report success after unmounting a pending form', async () => {
117+
const request = deferred<void>();
118+
const onSuccess = vi.fn();
119+
const { result, unmount } = renderHook(() =>
120+
useForm({
121+
initialValues: { username: 'alex' },
122+
onSubmit: () => request.promise,
123+
onSuccess,
124+
}),
125+
);
126+
act(() => result.current.submit());
127+
unmount();
128+
await act(async () => {
129+
request.resolve();
130+
await tick();
131+
});
132+
expect(onSuccess).not.toHaveBeenCalled();
133+
});
134+
135+
it('suppresses success when the request settles during unmount layout cleanup', async () => {
136+
const request = deferred<void>();
137+
const onSuccess = vi.fn();
138+
const { result, unmount } = renderHook(() => {
139+
const form = useForm({ initialValues: { username: 'alex' }, onSubmit: () => request.promise, onSuccess });
140+
useLayoutEffect(() => () => request.resolve(), []);
141+
return form;
142+
});
143+
act(() => result.current.submit());
144+
unmount();
145+
await act(tick);
146+
expect(onSuccess).not.toHaveBeenCalled();
147+
});
148+
149+
it('reports success once when StrictMode replays effects during submission', async () => {
150+
const request = deferred<void>();
151+
const onSubmit = vi.fn(() => request.promise);
152+
const onEffect = vi.fn();
153+
const onSuccess = vi.fn();
154+
renderHook(
155+
() => {
156+
const form = useForm({ initialValues: { username: 'alex' }, onSubmit, onSuccess });
157+
const { submit } = form;
158+
useEffect(() => {
159+
onEffect();
160+
submit();
161+
}, [submit]);
162+
return form;
163+
},
164+
{ wrapper: ({ children }) => createElement(StrictMode, null, children) },
165+
);
166+
expect(onEffect).toHaveBeenCalledTimes(2);
167+
await act(async () => {
168+
request.resolve();
169+
await tick();
170+
});
171+
expect(onSubmit).toHaveBeenCalledOnce();
172+
expect(onSuccess).toHaveBeenCalledOnce();
173+
});
174+
175+
it('allows success to synchronously unmount the form before pending clears', async () => {
176+
const request = deferred<void>();
177+
const onSuccess = vi.fn(() => {
178+
expect(result.current.isSubmitting).toBe(true);
179+
flushSync(() => unmount());
180+
});
181+
const { result, unmount } = renderHook(() =>
182+
useForm({
183+
initialValues: { username: 'alex' },
184+
onSubmit: () => request.promise,
185+
onSuccess,
186+
}),
187+
);
188+
act(() => result.current.submit());
189+
await act(async () => {
190+
request.resolve();
191+
await tick();
192+
});
193+
expect(onSuccess).toHaveBeenCalledOnce();
194+
});
195+
69196
it('submits a value set in the same tick', () => {
70197
const onSubmit = vi.fn(resolved);
71198
const { result } = renderHook(() => useForm({ initialValues: { code: '' }, onSubmit }));

‎packages/mosaic/src/components/form/form.machine.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export interface AsyncFieldState {
4141
export interface FormDeps<TValues extends object> {
4242
initialValues: TValues;
4343
fields: FieldsConfig<TValues> | undefined;
44-
onSubmit: (values: TValues) => Promise<unknown>;
44+
onSubmit: (values: TValues) => Promise<() => void>;
4545
canSubmit: (values: TValues) => boolean;
4646
fallbackMessage: string;
4747
errorText: (error: LocalizableError) => string;
@@ -251,7 +251,7 @@ export function createFormMachine<TValues extends object>(deps: FormDeps<TValues
251251
invoke: fromPromise(async ctx => ctx.onSubmit(ctx.values), {
252252
onDone: {
253253
target: 'editing',
254-
actions: assign(() => ({ error: undefined })),
254+
actions: [assign(() => ({ error: undefined })), (_, event) => event.output()],
255255
},
256256
onError: [
257257
{
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { expectTypeOf, test } from 'vitest';
2+
3+
import type { UseFormOptions, UseFormResult } from './use-form';
4+
import { useForm } from './use-form';
5+
6+
test('infers success result and submitted values from the submission', () => {
7+
useForm({
8+
initialValues: { username: 'alex' },
9+
onSubmit: values => Promise.resolve({ id: values.username, count: 1 }),
10+
onSuccess: (result, submittedValues) => {
11+
expectTypeOf(result).toEqualTypeOf<{ id: string; count: number }>();
12+
expectTypeOf(submittedValues).toEqualTypeOf<{ username: string }>();
13+
},
14+
});
15+
});
16+
17+
test('preserves explicit values generics and options without a success callback', () => {
18+
const options: UseFormOptions<{ username: string }> = {
19+
initialValues: { username: 'alex' },
20+
onSubmit: () => Promise.resolve({ id: 'saved' }),
21+
};
22+
expectTypeOf(useForm<{ username: string }>(options)).toEqualTypeOf<UseFormResult<{ username: string }>>();
23+
});
24+
25+
test('infers a void result for submissions without a return value', () => {
26+
useForm({
27+
initialValues: { username: 'alex' },
28+
onSubmit: () => Promise.resolve(),
29+
onSuccess: result => {
30+
expectTypeOf(result).toEqualTypeOf<void>();
31+
},
32+
});
33+
});

‎packages/mosaic/src/components/form/use-form.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import type { FieldsConfig, FormContext, FormEvent } from './form.machine';
88
import { createFormMachine, fieldConfig, fieldFeedback, firstInvalid, initialOf, isValid } from './form.machine';
99
import type { FieldFeedback } from './form-submit-error';
1010

11-
export interface UseFormOptions<TValues extends object> {
11+
export interface UseFormOptions<TValues extends object, TResult = unknown> {
1212
initialValues: TValues;
1313
fields?: FieldsConfig<TValues>;
14-
onSubmit: (values: TValues) => Promise<unknown>;
14+
onSubmit: (values: TValues) => Promise<TResult>;
15+
onSuccess?: (result: TResult, submittedValues: TValues) => void;
1516
canSubmit?: (values: TValues) => boolean;
1617
errorFallback?: string;
1718
}
@@ -82,7 +83,9 @@ type ElementRef = (element: HTMLElement | null) => void;
8283

8384
const always = () => true;
8485

85-
export function useForm<TValues extends object>(options: UseFormOptions<TValues>): UseFormResult<TValues> {
86+
export function useForm<TValues extends object, TResult = unknown>(
87+
options: UseFormOptions<TValues, TResult>,
88+
): UseFormResult<TValues> {
8689
const id = useId();
8790
const m = useMessages('form');
8891
const errorText = useErrorText();
@@ -92,7 +95,11 @@ export function useForm<TValues extends object>(options: UseFormOptions<TValues>
9295
const deps = {
9396
initialValues: options.initialValues,
9497
fields: options.fields,
95-
onSubmit: options.onSubmit,
98+
onSubmit: async (submittedValues: TValues) => {
99+
const { onSubmit, onSuccess } = options;
100+
const result = await onSubmit(submittedValues);
101+
return () => onSuccess?.(result, submittedValues);
102+
},
96103
canSubmit: options.canSubmit ?? always,
97104
fallbackMessage: options.errorFallback ?? m.error,
98105
errorText,

‎packages/mosaic/src/features/api-keys/create-api-key.controller.ts‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,16 @@ export function useCreateAPIKeyController({
4949
if (expiration === null) {
5050
return;
5151
}
52-
setSecret(
53-
await onCreate({
54-
name: name.trim(),
55-
description: description.trim() || undefined,
56-
expiresAt: getExpirationDate(expiration, new Date()),
57-
}),
58-
);
52+
return onCreate({
53+
name: name.trim(),
54+
description: description.trim() || undefined,
55+
expiresAt: getExpirationDate(expiration, new Date()),
56+
});
57+
},
58+
onSuccess: secret => {
59+
if (secret !== undefined) {
60+
setSecret(secret);
61+
}
5962
},
6063
});
6164

0 commit comments

Comments
 (0)