diff --git a/.changeset/goofy-parents-trade.md b/.changeset/goofy-parents-trade.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/goofy-parents-trade.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md
index 04fbe3de9e7..f2efa863c05 100644
--- a/packages/mosaic/AGENTS.md
+++ b/packages/mosaic/AGENTS.md
@@ -94,6 +94,7 @@ Most problems a feature hits already have a shared answer. Find the line for you
- One screen replacing another on the same card or dialog → `Flow` (`src/components/flow/`), see [Views](docs/views.md)
- An interaction with an async lifecycle or two values that move together → A machine in the controller file, via `setup()` (`src/machine/`)
- Styles several components share → `src/styles/*.styles.ts`
+- A machine-driven step that a lock or caller must await → `waitFor` (`src/machine/wait-for.ts`), inside `usePendingAction.run`
- Anything else small: a permission, verification codes, backup codes, avatar files, a list patch → List `src/utils/`, `src/hooks/` and `src/primitives/hooks/` first; most of these already have a helper
## Signs you are about to invent a pattern
diff --git a/packages/mosaic/docs/controllers.md b/packages/mosaic/docs/controllers.md
index 20178a70609..3e20ed5293f 100644
--- a/packages/mosaic/docs/controllers.md
+++ b/packages/mosaic/docs/controllers.md
@@ -66,6 +66,7 @@ Pass `isDisabled` to every control that starts one of the actions, including the
- `run` resolves `true` on success, so the caller can close a dialog or move focus. A cancelled reverification resolves `false`, shows no error and releases the lock.
- To hold pending through an OAuth redirect, await a delay inside the action (`user-profile-connected-accounts-section.controller.ts`).
+- To hold it through a step a machine drives, run an action that awaits `waitFor` (`machine/wait-for.ts`) until the machine leaves that state (`user-profile-add-email.controller.ts`).
- It is not for a form (`useForm`), a confirm step (`useConfirmController`), an action whose result drives other state (a machine), or a check that reruns as the user types (`useDebouncedAsync`, or a field's `validateAsync`).
For a direct action in `ActionMenu`, opt in with `status: 'idle'` and set it to `'pending'` in the click event while the request runs and no dialog is open yet; the menu closes on the next render unless an action is pending. While pending, the menu reserves room for a spinner, disables its actions and blocks dismissal. It closes when the status returns to `'idle'`. Leave `status` undefined for ordinary actions that close through the menu's own click.
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx
index 5a61178dc54..2bc90bef87f 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx
@@ -158,6 +158,27 @@ describe('connected accounts', () => {
request.fail();
});
+ it('holds connected account menus while a connection starts', async () => {
+ await renderSection([google]);
+ const request = holdRequests('post', '/v1/me/external_accounts');
+ const user = userEvent.setup();
+
+ try {
+ await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ const manage = screen.getByRole('button', { name: 'Manage Google' });
+ expect(manage).toHaveAttribute('aria-disabled', 'true');
+ await user.click(manage);
+ expect(screen.queryByRole('menu')).toBeNull();
+ } finally {
+ request.fail();
+ }
+
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Manage Google' })).not.toHaveAttribute('aria-disabled', 'true'),
+ );
+ });
+
it('verifies the session before connecting a provider, then continues to it', async () => {
serveFapi(signedIn([], { verification: { secrets: { password: 'hunter2' } } }));
const view = await renderWithClerk();
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx
index 80730e8551f..6937edb467c 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx
@@ -19,13 +19,11 @@ import type {
export function UserProfileConnectProviderRowView({
provider,
- isPending,
isDisabled,
onConnect,
triggerRef,
}: {
provider: UserProfileConnectionProvider;
- isPending: boolean;
isDisabled: boolean;
onConnect: (id: string) => void;
triggerRef?: Ref;
@@ -58,8 +56,8 @@ export function UserProfileConnectProviderRowView({
variant='outline'
color='neutral'
aria-label={fill(m.connectLabel, { provider: provider.provider })}
- isPending={isPending}
- disabled={isDisabled && !isPending}
+ isPending={provider.isPending}
+ disabled={isDisabled && !provider.isPending}
onClick={() => onConnect(provider.id)}
>
{m.connect}
@@ -91,7 +89,7 @@ export function UserProfileConnectedAccountRowView({
}) {
const m = useMessages('userProfile').connectedAccounts;
const actions: ActionMenuAction[] = [];
- if ((account.status === 'reconnect' || account.status === 'error') && onReconnect && !isDisabled) {
+ if ((account.status === 'reconnect' || account.status === 'error') && onReconnect) {
actions.push({
label: account.status === 'reconnect' ? m.reconnect : m.tryAgain,
onClick: () => onReconnect(account.id),
@@ -131,6 +129,8 @@ export function UserProfileConnectedAccountRowView({
{actions.length > 0 ? (
void;
onReconnect: (id: string) => void;
sectionRef: Ref;
@@ -81,10 +81,12 @@ export function useUserProfileConnectedAccountsController({
accounts: accounts.map(account =>
actions.errorKey === account.id ? { ...account, reconnectError: actions.error } : account,
),
- availableProviders: availableProviders.map(provider =>
- actions.errorKey === provider.id ? { ...provider, connectError: actions.error } : provider,
- ),
- pendingId: actions.pendingKey,
+ availableProviders: availableProviders.map(provider => ({
+ ...provider,
+ isPending: actions.pendingKey === provider.id,
+ connectError: actions.errorKey === provider.id ? actions.error : undefined,
+ })),
+ isDisabled: actions.isPending || remove.isPending,
onConnect: id => run(id, onConnect),
onReconnect: id => run(id, onReconnect),
sectionRef,
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts
index bfd25079a0b..5d8a2e0b891 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts
@@ -16,6 +16,7 @@ export interface ConnectedAccountProviderDisplay {
export interface UserProfileConnectionProvider extends ConnectedAccountProviderDisplay {
id: string;
connectError?: string;
+ isPending?: boolean;
}
export interface UserProfileConnectedAccount extends ConnectedAccountProviderDisplay {
@@ -30,7 +31,7 @@ export interface UserProfileConnectedAccountsSectionViewProps {
accounts: UserProfileConnectedAccount[];
sectionRef?: Ref;
availableProviders?: UserProfileConnectionProvider[];
- pendingId?: string;
+ isDisabled?: boolean;
onConnect?: (id: string) => void;
onReconnect?: (id: string) => void;
registerTrigger: (id: string) => Ref;
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx
index 0ce9ea67d93..a8fe2660ba4 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx
@@ -18,7 +18,7 @@ export function UserProfileConnectedAccountsSectionView({
accounts,
sectionRef,
availableProviders = [],
- pendingId,
+ isDisabled = false,
onConnect,
onReconnect,
registerTrigger,
@@ -31,7 +31,6 @@ export function UserProfileConnectedAccountsSectionView({
}: UserProfileConnectedAccountsSectionViewProps) {
const m = useMessages('userProfile').connectedAccounts;
const hasRows = accounts.length > 0 || (availableProviders.length > 0 && Boolean(onConnect));
- const isBusy = pendingId !== undefined;
return (
<>
@@ -52,7 +51,7 @@ export function UserProfileConnectedAccountsSectionView({
key={account.id}
account={account}
triggerRef={registerTrigger(account.id)}
- isDisabled={isBusy}
+ isDisabled={isDisabled}
onReconnect={onReconnect}
onRemove={remove ? account => remove.open(account) : undefined}
/>
@@ -63,8 +62,7 @@ export function UserProfileConnectedAccountsSectionView({
key={provider.id}
provider={provider}
triggerRef={registerTrigger(provider.id)}
- isPending={pendingId === provider.id}
- isDisabled={isBusy}
+ isDisabled={isDisabled}
onConnect={onConnect}
/>
))
diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts
index d212a1da3f8..dbb19ec871e 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts
@@ -222,7 +222,7 @@ describe('useUserProfileAddEmailController', () => {
useUserProfileAddEmailController({ reverification: idleReverification, onCreate }),
);
- act(() => result.current.onVerifyEmail(other, email));
+ act(() => void result.current.onVerifyEmail(other, email));
expect(email.start).toHaveBeenCalledOnce();
expect(result.current.open).toBe(false);
expect(result.current.pendingVerifyId).toBe('email_2');
@@ -249,11 +249,11 @@ describe('useUserProfileAddEmailController', () => {
);
expect(result.current.verifyingId).toBeUndefined();
- act(() => result.current.onVerifyEmail(other, email));
+ act(() => void result.current.onVerifyEmail(other, email));
expect(result.current.verifyingId).toBe('email_2');
await waitFor(() => expect(result.current.step).toBe('verify'));
- act(() => result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email));
+ act(() => void result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email));
expect(result.current.verifyingId).toBe('email_2');
expect(result.current.emailAddress).toBe('other@example.com');
@@ -275,7 +275,7 @@ describe('useUserProfileAddEmailController', () => {
useUserProfileAddEmailController({ reverification: idleReverification, onCreate: created }),
);
- act(() => result.current.onVerifyEmail(other, email));
+ act(() => void result.current.onVerifyEmail(other, email));
await waitFor(() => expect(result.current.errorMessage).toBe('Too many requests'));
expect(result.current.step).toBe('verify');
expect(result.current.resendSeconds).toBe(0);
@@ -301,7 +301,7 @@ describe('useUserProfileAddEmailController', () => {
const verified = createDeferredPromise();
const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification }));
- act(() => result.current.onVerifyEmail(other, verifier(sentLink(verified.promise.then(() => undefined)))));
+ act(() => void result.current.onVerifyEmail(other, verifier(sentLink(verified.promise.then(() => undefined)))));
expect(result.current.step).toBe('link');
expect(result.current.resendSeconds).toBe(60);
@@ -319,12 +319,12 @@ describe('useUserProfileAddEmailController', () => {
useUserProfileAddEmailController({ reverification: idleReverification }),
);
- act(() => result.current.onVerifyEmail(other, email));
+ act(() => void result.current.onVerifyEmail(other, email));
await waitFor(() => expect(result.current.step).toBe('link'));
act(() => result.current.onOpenChange(false));
expect(cancel).toHaveBeenCalledOnce();
- act(() => result.current.onVerifyEmail(other, email));
+ act(() => void result.current.onVerifyEmail(other, email));
await waitFor(() => expect(result.current.step).toBe('link'));
unmount();
expect(cancel).toHaveBeenCalledTimes(2);
@@ -334,7 +334,7 @@ describe('useUserProfileAddEmailController', () => {
const email = verifier(sentLink(Promise.reject(saveError('Link expired'))));
const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification }));
- act(() => result.current.onVerifyEmail(other, email));
+ act(() => void result.current.onVerifyEmail(other, email));
await waitFor(() => expect(result.current.errorMessage).toBe('Link expired'));
expect(result.current.step).toBe('link');
expect(result.current.open).toBe(true);
@@ -345,7 +345,7 @@ describe('useUserProfileAddEmailController', () => {
const email = verifier(() => ({ method: 'sso', connect }));
const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification }));
- act(() => result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email));
+ act(() => void result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email));
expect(result.current.step).toBe('sso');
expect(result.current.resendSeconds).toBe(0);
diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx
index f9789877eae..c535f4bbf00 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx
@@ -189,12 +189,18 @@ describe('the user profile email section', () => {
expect(screen.getByRole('menuitem', { name: 'Verify' })).toHaveAttribute('aria-busy', 'true');
expect(await screen.findByRole('progressbar', { name: 'Verify' })).toBeVisible();
expect(screen.queryByRole('dialog')).toBeNull();
+ expect(screen.getByRole('button', { name: 'Manage alice@example.com' })).toHaveAttribute('aria-disabled', 'true');
+ expect(screen.getByRole('button', { name: 'Add email' })).toBeDisabled();
} finally {
prepare.release();
}
await enterCode(actor, VERIFICATION_CODE);
expect(screen.queryByRole('menu')).toBeNull();
+ expect(screen.getByRole('button', { name: 'Manage alice@example.com' })).not.toHaveAttribute(
+ 'aria-disabled',
+ 'true',
+ );
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts
index 89ccd25abee..7710d928143 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts
@@ -8,6 +8,7 @@ import { useErrorText } from '../../../localization';
import { setup } from '../../../machine/setup';
import type { ErrorInvokeEvent } from '../../../machine/types';
import { useMachine } from '../../../machine/use-machine';
+import { waitFor } from '../../../machine/wait-for';
import type { FormError } from '../../../utils/errors';
import { toFormError } from '../../../utils/errors';
import { CODE_LENGTH, CODE_RESEND_SECONDS, isResendUnlocked, resendUnlockTime } from '../../../utils/verification';
@@ -32,7 +33,7 @@ export interface UserProfileAddEmailController extends UserProfileAddEmailDialog
handle: DialogHandle;
verifyingId: string | undefined;
pendingVerifyId: string | undefined;
- onVerifyEmail: (email: Pick, verifier: UserProfileEmailVerifier) => void;
+ onVerifyEmail: (email: Pick, verifier: UserProfileEmailVerifier) => Promise;
}
interface Context {
@@ -268,9 +269,10 @@ export function useUserProfileAddEmailController({
onConnect: () => send({ type: 'CONNECT' }),
verifyingId: snapshot.context.verifyingId,
pendingVerifyId: state === 'preparing' ? snapshot.context.verifyingId : undefined,
- onVerifyEmail: (email, verifier) => {
+ onVerifyEmail: async (email, verifier) => {
codeForm.reset();
send({ type: 'START', id: email.id, emailAddress: email.value, verifier });
+ await waitFor(actor, current => current.value !== 'preparing');
},
};
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts
index f9c73803cb6..14f66484e24 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts
@@ -58,7 +58,7 @@ export function useUserProfileEmailSectionController({
onVerify: id => {
const email = emails.find(email => email.id === id);
if (email) {
- verification.onVerifyEmail(email, getEmailVerifier(id));
+ void actions.run(id, () => verification.onVerifyEmail(email, getEmailVerifier(id)));
}
},
onSetPrimary: id => void actions.run(id, () => onSetPrimaryEmail(id)),
diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx
index 043343f82f4..635e28d3931 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx
@@ -14,12 +14,10 @@ import type { UserProfileEnterpriseAccount } from './user-profile-enterprise-acc
export function UserProfileEnterpriseAccountRowView({
account,
onConnect,
- isPending,
disabled,
}: {
account: UserProfileEnterpriseAccount;
onConnect?: (id: string) => void;
- isPending?: boolean;
disabled?: boolean;
}) {
const m = useMessages('userProfile').enterpriseAccounts;
@@ -58,7 +56,7 @@ export function UserProfileEnterpriseAccountRowView({
size='sm'
variant='outline'
aria-label={fill(m.connectProvider, { provider: account.name })}
- isPending={isPending}
+ isPending={account.isPending}
pendingLabel={m.connecting}
disabled={disabled}
onClick={() => onConnect(account.id)}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts
index d92ec9df897..7e36b725538 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts
@@ -18,7 +18,7 @@ export interface UserProfileEnterpriseAccountsControllerOptions {
export interface UserProfileEnterpriseAccountsController {
accounts: UserProfileEnterpriseAccount[];
connections: UserProfileEnterpriseConnection[];
- pendingId: string | undefined;
+ isDisabled: boolean;
onConnect: (connectionId: string) => void;
connectReverification: ReverificationController;
}
@@ -34,10 +34,12 @@ export function useUserProfileEnterpriseAccountsController({
return {
accounts,
- connections: connections.map(connection =>
- actions.errorKey === connection.id ? { ...connection, connectError: actions.error } : connection,
- ),
- pendingId: actions.pendingKey,
+ connections: connections.map(connection => ({
+ ...connection,
+ isPending: actions.pendingKey === connection.id,
+ connectError: actions.errorKey === connection.id ? actions.error : undefined,
+ })),
+ isDisabled: actions.isPending,
onConnect: id =>
void actions.run(id, async () => {
if ((await onConnect(id)) === 'redirecting') {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts
index 9f43d1bf1af..3e09e948d21 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts
@@ -4,6 +4,7 @@ export interface UserProfileEnterpriseConnection {
iconUrl?: string;
monochromeIcon?: boolean;
connectError?: string;
+ isPending?: boolean;
}
export interface UserProfileEnterpriseAccount extends UserProfileEnterpriseConnection {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx
index ca9ba722029..c2144b7382c 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx
@@ -11,7 +11,7 @@ import type {
export interface UserProfileEnterpriseAccountsSectionViewProps {
accounts: UserProfileEnterpriseAccount[];
connections?: UserProfileEnterpriseConnection[];
- pendingId?: string;
+ isDisabled?: boolean;
onConnect?: (id: string) => void;
connectReverification?: ReverificationController;
}
@@ -19,7 +19,7 @@ export interface UserProfileEnterpriseAccountsSectionViewProps {
export function UserProfileEnterpriseAccountsSectionView({
accounts,
connections = [],
- pendingId,
+ isDisabled = false,
onConnect,
connectReverification,
}: UserProfileEnterpriseAccountsSectionViewProps) {
@@ -49,8 +49,7 @@ export function UserProfileEnterpriseAccountsSectionView({
key={connection.id}
account={connection}
onConnect={onConnect}
- isPending={pendingId === connection.id}
- disabled={pendingId !== undefined}
+ disabled={isDisabled}
/>
))
: null}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx
index f146797e6cf..61781b09b0f 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx
@@ -367,6 +367,41 @@ describe('User profile MFA management', () => {
}
});
+ it('blocks backup-code regeneration while the default SMS method is changing', async () => {
+ const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true });
+ const second = fapiPhoneNumber({
+ ...phone,
+ id: 'phone_2',
+ phone_number: '+15555550202',
+ reserved_for_second_factor: true,
+ });
+ const fapi = await renderMfa(
+ fapiUser({ id: 'user_1', phone_numbers: [first, second], backup_code_enabled: true, two_factor_enabled: true }),
+ );
+ const held = holdRequests('post', '/v1/me/phone_numbers/phone_2');
+ const user = userEvent.setup();
+ try {
+ await openMenu(user, screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0202' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Set as default' }));
+ await waitFor(() => expect(held.requests).toHaveLength(1));
+ await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
+ const manage = screen.getByRole('button', { name: 'Manage Backup codes' });
+ expect(manage).toHaveAttribute('aria-disabled', 'true');
+ await user.click(manage);
+ expect(screen.queryByRole('menu')).toBeNull();
+ expect(fapi.mfa.backupCodeCreations).toBe(0);
+ expect(screen.getByRole('button', { name: 'Add verification method' })).toBeDisabled();
+ } finally {
+ held.release();
+ }
+ const manage = screen.getByRole('button', { name: 'Manage Backup codes' });
+ await waitFor(() => expect(manage).not.toHaveAttribute('aria-disabled', 'true'));
+ await openMenu(user, manage);
+ await user.click(screen.getByRole('menuitem', { name: 'Regenerate' }));
+ await waitFor(() => expect(screen.getByText('CODE0100')).toBeVisible());
+ expect(fapi.mfa.backupCodeCreations).toBe(1);
+ });
+
it('returns failed regeneration to the section and retries from the menu', async () => {
const fapi = await renderMfa(
fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }),
@@ -421,6 +456,7 @@ describe('User profile MFA management', () => {
expect(screen.getByRole('menuitem', { name: 'Regenerate' })).toHaveAttribute('aria-busy', 'true');
expect(await screen.findByRole('progressbar', { name: 'Regenerate' })).toBeVisible();
expect(screen.queryByRole('dialog')).toBeNull();
+ expect(screen.getByRole('button', { name: 'Manage Authenticator app' })).toHaveAttribute('aria-disabled', 'true');
} finally {
generate.release();
}
@@ -428,6 +464,10 @@ describe('User profile MFA management', () => {
const list = await screen.findByRole('list', { name: 'Backup codes' });
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
+ expect(screen.getByRole('button', { name: 'Manage Authenticator app', hidden: true })).not.toHaveAttribute(
+ 'aria-disabled',
+ 'true',
+ );
});
it('holds the Regenerate menu item until session verification opens', async () => {
@@ -865,6 +905,12 @@ describe('User profile MFA management', () => {
expect(screen.getAllByText('SMS verification')[0]?.closest('li')).toHaveTextContent('+1 (555) 555-0202'),
);
expect(screen.getAllByText('SMS verification')[0]?.closest('li')).toHaveTextContent('Default');
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })).not.toHaveAttribute(
+ 'aria-disabled',
+ 'true',
+ ),
+ );
await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' }));
await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as default' })).toBeVisible());
});
@@ -908,6 +954,12 @@ describe('User profile MFA management', () => {
expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_2', default: true, reserved: undefined }),
);
expect(attempts).toBe(2);
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })).not.toHaveAttribute(
+ 'aria-disabled',
+ 'true',
+ ),
+ );
await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' }));
await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as default' })).toBeVisible());
});
@@ -916,7 +968,7 @@ describe('User profile MFA management', () => {
{ phase: 'mutation', method: 'post', path: '/v1/me/phone_numbers/phone_2', pendingPhone: '+1 (555) 555-0202' },
{ phase: 'refresh', method: 'get', path: '/v1/me', pendingPhone: '+1 (555) 555-0101' },
] as const)(
- 'keeps method menus available but hides default changes during $phase',
+ 'disables method menus while changing the default during $phase',
async ({ method, path, pendingPhone }) => {
const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true });
const second = fapiPhoneNumber({
@@ -934,16 +986,21 @@ describe('User profile MFA management', () => {
await waitFor(() => expect(held.requests).toHaveLength(1));
try {
const pendingAction = screen.getByRole('button', { name: `Manage SMS verification ${pendingPhone}` });
- await openMenu(user, pendingAction);
- await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Remove method' })).toBeVisible());
- expect(screen.queryByRole('menuitem', { name: 'Set as default' })).toBeNull();
- await user.click(await screen.findByRole('menuitem', { name: 'Remove method' }));
+ expect(pendingAction).toHaveAttribute('aria-disabled', 'true');
+ await user.click(pendingAction);
+ expect(screen.queryByRole('menu')).toBeNull();
expect(screen.queryByRole('alertdialog')).toBeNull();
} finally {
held.release();
}
await waitFor(() => expect(screen.getByText('+1 (555) 555-0202').closest('li')).toHaveTextContent('Default'));
await user.keyboard('{Escape}');
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })).not.toHaveAttribute(
+ 'aria-disabled',
+ 'true',
+ ),
+ );
await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' }));
await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as default' })).toBeVisible());
},
diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx
index cffe0b4ebdf..10a6d93c07b 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx
@@ -11,12 +11,14 @@ import type { UserProfileMfaMethod, UserProfileMfaSectionViewProps } from './use
export function UserProfileMfaRowView({
method,
+ isDisabled,
triggerRef,
onRemove,
onSetDefault,
regenerate,
}: Pick & {
method: UserProfileMfaMethod;
+ isDisabled?: boolean;
triggerRef?: Ref;
onRemove?: () => void;
onSetDefault?: (id: string) => void;
@@ -56,6 +58,8 @@ export function UserProfileMfaRowView({
{actions.length > 0 ? (
sectionRef.current?.querySelector('button:not([disabled])') ?? sectionRef.current,
});
+ const localPendingAction = usePendingAction();
+ const actions = pendingAction ?? localPendingAction;
const remove = useConfirmController({
onConfirm: method => removalFocus.remove(method.id),
reverification: removeReverification,
});
const [pickerOpen, setPickerOpen] = useState(false);
- const defaultAction = usePendingAction({ errorFallback: m.setDefaultError });
const setDefault = (id: string) => {
const method = methods.find(method => method.id === id);
if (!onSetDefault || method?.type !== 'sms' || !method.canSetDefault || method.isDefault) {
return;
}
- void defaultAction.run(id, () => onSetDefault(id));
+ void actions.run(id, () => onSetDefault(id), { errorFallback: m.setDefaultError });
};
return {
sectionRef,
+ isDisabled: actions.isPending || remove.isPending,
pickerOpen,
onPickerOpenChange: setPickerOpen,
addMethod: onAdd
@@ -49,16 +52,13 @@ export function useUserProfileMfaSectionLeafController({
: undefined,
registerTrigger: removalFocus.registerTrigger,
openRemoval: (method: UserProfileMfaMethod) => {
- if (defaultAction.isPending) {
- return;
- }
- defaultAction.reset();
+ actions.reset();
remove.open(method);
},
remove: onRemove ? remove : undefined,
finalRemovalFocus: removalFocus.finalFocus,
- setDefaultFinalFocus: () => (defaultAction.lastKey ? removalFocus.trigger(defaultAction.lastKey) : null),
- defaultError: defaultAction.error,
- setDefault: onSetDefault && !defaultAction.isPending ? setDefault : undefined,
+ setDefaultFinalFocus: () => (actions.lastKey ? removalFocus.trigger(actions.lastKey) : null),
+ defaultError: actions.error,
+ setDefault: onSetDefault ? setDefault : undefined,
};
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts
index 9c6de127f1b..f8cdbc799fc 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts
@@ -1,5 +1,6 @@
import { isReverificationCancelledError } from '@clerk/shared/error';
+import { usePendingAction } from '../../../hooks/use-pending-action';
import { useResendSeconds } from '../../../hooks/use-resend-seconds';
import { type ErrorDescription, fill, type MosaicMessages, useErrorText, useMessages } from '../../../localization';
import { setup } from '../../../machine/setup';
@@ -351,9 +352,9 @@ function createBackupCodeActions(
const generateBackupCodes = () => {
const generate = model.generateBackupCodes;
if (!generate) {
- return;
+ return Promise.resolve();
}
- void run('generate', async () => {
+ return run('generate', async () => {
const codes = await generate();
return { kind: 'backup', codes };
}).catch(() => undefined);
@@ -482,6 +483,7 @@ function createSetupProps(
}
export function useUserProfileMfaController(model: ReadyModel) {
+ const pendingAction = usePendingAction();
const errorText = useErrorText();
const backupCodeMessages = useMessages('userProfile').mfa.backupCodes;
const [{ context, value }, send] = useMachine(mfaMachine);
@@ -528,7 +530,7 @@ export function useUserProfileMfaController(model: ReadyModel) {
prepareAuthenticator();
}
} else if (method === 'backup-codes') {
- generateBackupCodes();
+ void generateBackupCodes();
} else {
send({ type: 'SELECT_SMS', phoneId: model.phones[0]?.id ?? '', hasPhones: model.phones.length > 0 });
}
@@ -545,7 +547,7 @@ export function useUserProfileMfaController(model: ReadyModel) {
submitSms,
resendSms,
verifyAuthenticator,
- generateBackupCodes,
+ generateBackupCodes: () => void generateBackupCodes(),
copy,
download,
print,
@@ -557,11 +559,12 @@ export function useUserProfileMfaController(model: ReadyModel) {
);
const sectionProps: UserProfileMfaSectionViewProps = {
+ pendingAction,
methods: [...model.methods],
addableMethods: model.addableMethods,
regenerate: model.generateBackupCodes
? {
- onClick: generateBackupCodes,
+ onClick: () => void pendingAction.run('regenerate', generateBackupCodes),
status: context.pending === 'generate' && !dialogOpen ? 'pending' : 'idle',
error: flow.kind === 'closed' && flow.error ? errorText(flow.error) : undefined,
}
@@ -579,7 +582,7 @@ export function useUserProfileMfaController(model: ReadyModel) {
dialogOpen,
showAddTrigger,
showAddControl: showAddTrigger || dialogOpen,
- isPending: isBusy,
+ isPending: isBusy || pendingAction.isPending,
reverifying,
onDialogOpenChange: (open: boolean) => {
if (!open && !canClose) {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx
index e4609cf442b..de7d2006951 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx
@@ -4,6 +4,7 @@ import type { ReactNode, Ref } from 'react';
import { Confirmation } from '../../../blocks/confirmation';
import { ReverificationDialog } from '../../../blocks/reverification';
import { Section } from '../../../components/section';
+import type { PendingAction } from '../../../hooks/use-pending-action';
import { fill, type MosaicMessages, useMessages } from '../../../localization';
import type { ReverificationController } from '../../reverification';
import { UserProfileSecurityList } from '../user-profile-security-list';
@@ -17,6 +18,7 @@ export type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-p
export interface UserProfileMfaSectionViewProps {
methods: UserProfileMfaMethod[];
+ pendingAction?: PendingAction;
addableMethods?: readonly UserProfileMfaAddableMethod[];
addButtonRef?: Ref;
addControl?: ReactNode;
@@ -30,6 +32,7 @@ export interface UserProfileMfaSectionViewProps {
export function UserProfileMfaSectionView({
methods,
+ pendingAction,
addableMethods,
addButtonRef,
addControl,
@@ -43,6 +46,7 @@ export function UserProfileMfaSectionView({
const m = useMessages('userProfile').mfa;
const controller = useUserProfileMfaSectionLeafController({
methods,
+ pendingAction,
onAdd,
onRemove,
onSetDefault,
@@ -58,6 +62,7 @@ export function UserProfileMfaSectionView({
(controller.addMethod && addableMethods?.length ? (
@@ -77,6 +82,7 @@ export function UserProfileMfaSectionView({
controller.openRemoval(method) : undefined}
onSetDefault={controller.setDefault}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts
index ba8b84cb7cc..7c5c5e6a0ef 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts
@@ -43,12 +43,12 @@ describe('useUserProfileAddPhoneController', () => {
const verifier = { sendCode, verifyCode: () => Promise.resolve() };
expect(result.current.verifyingId).toBeUndefined();
- act(() => result.current.onVerifyPhone({ id: 'phone_2', value: '+18015550101' }, verifier));
+ act(() => void result.current.onVerifyPhone({ id: 'phone_2', value: '+18015550101' }, verifier));
expect(result.current.verifyingId).toBe('phone_2');
await act(async () => vi.advanceTimersByTimeAsync(0));
expect(result.current.step).toBe('verify');
- act(() => result.current.onVerifyPhone({ id: 'phone_3', value: '+18015550102' }, verifier));
+ act(() => void result.current.onVerifyPhone({ id: 'phone_3', value: '+18015550102' }, verifier));
expect(result.current.verifyingId).toBe('phone_2');
expect(result.current.phoneNumber).toBe('+18015550101');
diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx
index 80952c1fe7b..a718e40d106 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx
@@ -312,6 +312,8 @@ describe('the user profile phone section', () => {
expect(screen.getByRole('menuitem', { name: 'Verify phone number' })).toHaveAttribute('aria-busy', 'true');
expect(await screen.findByRole('progressbar', { name: 'Verify phone number' })).toBeVisible();
expect(screen.queryByRole('dialog')).toBeNull();
+ expect(screen.getByRole('button', { name: `Manage ${HOME_LABEL}` })).toHaveAttribute('aria-disabled', 'true');
+ expect(screen.getByRole('button', { name: 'Add phone number' })).toBeDisabled();
} finally {
prepare.release();
}
@@ -319,6 +321,7 @@ describe('the user profile phone section', () => {
await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus());
await enterCode(actor, VERIFICATION_CODE);
expect(screen.queryByRole('menu')).toBeNull();
+ expect(screen.getByRole('button', { name: `Manage ${HOME_LABEL}` })).not.toHaveAttribute('aria-disabled', 'true');
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts
index b3f9d53d912..33cc8f35dba 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts
@@ -8,6 +8,7 @@ import { useErrorText } from '../../../localization';
import { setup } from '../../../machine/setup';
import type { ErrorInvokeEvent } from '../../../machine/types';
import { useMachine } from '../../../machine/use-machine';
+import { waitFor } from '../../../machine/wait-for';
import type { FormError } from '../../../utils/errors';
import { toFormError } from '../../../utils/errors';
import { CODE_RESEND_SECONDS, isResendUnlocked, resendUnlockTime } from '../../../utils/verification';
@@ -26,7 +27,7 @@ export interface UserProfileAddPhoneController extends UserProfileAddPhoneDialog
handle: DialogHandle;
verifyingId: string | undefined;
pendingVerifyId: string | undefined;
- onVerifyPhone: (phone: Pick, verifier: UserProfilePhoneVerifier) => void;
+ onVerifyPhone: (phone: Pick, verifier: UserProfilePhoneVerifier) => Promise;
}
interface Context {
@@ -198,9 +199,10 @@ export function useUserProfileAddPhoneController({
},
verifyingId: snapshot.context.verifyingId,
pendingVerifyId: state === 'preparing' ? snapshot.context.verifyingId : undefined,
- onVerifyPhone: (phone, verifier) => {
+ onVerifyPhone: async (phone, verifier) => {
codeForm.reset();
send({ type: 'START', id: phone.id, phoneNumber: phone.value, verifier });
+ await waitFor(actor, current => current.value !== 'preparing');
},
};
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts
index c5a587ad606..d127c320743 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts
@@ -60,7 +60,7 @@ export function useUserProfilePhoneSectionController({
onVerify: id => {
const phone = phones.find(phone => phone.id === id);
if (phone) {
- verification.onVerifyPhone(phone, getPhoneVerifier(id));
+ void actions.run(id, () => verification.onVerifyPhone(phone, getPhoneVerifier(id)));
}
},
onSetPrimary: id => void actions.run(id, () => onSetPrimaryPhone(id)),
diff --git a/packages/mosaic/src/machine/README.md b/packages/mosaic/src/machine/README.md
index f4205427a94..ce5f667ccc6 100644
--- a/packages/mosaic/src/machine/README.md
+++ b/packages/mosaic/src/machine/README.md
@@ -222,11 +222,12 @@ You've already seen `states`, `on`, `context`, `assign`, and `invoke`. The rest:
### API at a glance
-The exports live in `create-machine.ts`, `setup.ts`, `assign.ts`, `create-actor.ts` and `use-machine.ts`; their JSDoc is the reference. Which React hook to reach for:
+The exports live in `create-machine.ts`, `setup.ts`, `assign.ts`, `create-actor.ts`, `use-machine.ts` and `wait-for.ts`; their JSDoc is the reference. Which React hook to reach for:
- `useMachine(machine)` when the component owns the actor for its lifetime.
- `useActor(actor)` when the actor is shared or passed in, including a `mockActor` that renders a step directly.
- `useSelector(actor, selector)` when a component needs one slice and should re-render only when that slice changes.
+- `waitFor(actor, predicate)` when a caller needs a promise for a machine-driven step, such as holding a `usePendingAction` lock until the machine leaves a sending state.
### Two behaviors worth knowing
diff --git a/packages/mosaic/src/machine/__tests__/wait-for.test.ts b/packages/mosaic/src/machine/__tests__/wait-for.test.ts
new file mode 100644
index 00000000000..ba4f99d1e42
--- /dev/null
+++ b/packages/mosaic/src/machine/__tests__/wait-for.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+
+import { deferred, tick } from '../../__tests__/async';
+import { createActor } from '../create-actor';
+import { createMachine } from '../create-machine';
+import { waitFor } from '../wait-for';
+
+function startSending() {
+ const gate = deferred();
+ const machine = createMachine, { type: 'SEND' }>({
+ initial: 'idle',
+ states: {
+ idle: { on: { SEND: 'sending' } },
+ sending: { invoke: { src: () => gate.promise, onDone: 'sent' } },
+ sent: {},
+ },
+ });
+ const actor = createActor(machine);
+ actor.start();
+ return { actor, gate };
+}
+
+describe('waitFor', () => {
+ it('resolves with the current snapshot when it already matches', async () => {
+ const { actor } = startSending();
+
+ await expect(waitFor(actor, snapshot => snapshot.value === 'idle')).resolves.toMatchObject({ value: 'idle' });
+ });
+
+ it('resolves once the actor reaches a matching snapshot', async () => {
+ const { actor, gate } = startSending();
+ actor.send({ type: 'SEND' });
+ let settled = false;
+ const waiting = waitFor(actor, snapshot => snapshot.value !== 'sending').then(snapshot => {
+ settled = true;
+ return snapshot;
+ });
+
+ await tick();
+ expect(settled).toBe(false);
+
+ gate.resolve();
+ await expect(waiting).resolves.toMatchObject({ value: 'sent' });
+ });
+});
diff --git a/packages/mosaic/src/machine/wait-for.ts b/packages/mosaic/src/machine/wait-for.ts
new file mode 100644
index 00000000000..26099d794ab
--- /dev/null
+++ b/packages/mosaic/src/machine/wait-for.ts
@@ -0,0 +1,24 @@
+import type { Actor, EventObject, Snapshot } from './types';
+
+/**
+ * Resolves with the first snapshot that matches `predicate`, checking the current one first.
+ * Use it to await a machine-driven step, such as `actions.run(key, () => waitFor(actor, s => s.value !== 'sending'))`.
+ */
+export function waitFor(
+ actor: Pick, 'getSnapshot' | 'subscribe'>,
+ predicate: (snapshot: Snapshot) => boolean,
+): Promise> {
+ return new Promise(resolve => {
+ const current = actor.getSnapshot();
+ if (predicate(current)) {
+ resolve(current);
+ return;
+ }
+ const unsubscribe = actor.subscribe(snapshot => {
+ if (predicate(snapshot)) {
+ unsubscribe();
+ resolve(snapshot);
+ }
+ });
+ });
+}
diff --git a/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts b/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts
index 9316a74f1d1..f05eecd8016 100644
--- a/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts
+++ b/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts
@@ -50,8 +50,8 @@ export function useEnterpriseAccountsFixture({
return {
accounts: linkedAccounts,
- connections: availableConnections,
- pendingId,
+ connections: availableConnections.map(item => ({ ...item, isPending: item.id === pendingId })),
+ isDisabled: pendingId !== undefined,
onConnect: (id: string) => {
const connection = availableConnections.find(item => item.id === id);
if (!connection || pendingId) {