Skip to content
Closed
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/email-link-prepared-signal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clerk/clerk-js": patch
"@clerk/shared": patch
---

Email address link flows now accept an optional `onPrepared` callback that runs after the verification email is prepared, while the returned promise still waits for verification. Cancelling during preparation prevents subsequent polling.
2 changes: 2 additions & 0 deletions .changeset/ready-contact-dialogs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/steady-two-step-preparation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
27 changes: 23 additions & 4 deletions packages/clerk-js/src/core/resources/EmailAddress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
EmailAddressResource,
IdentificationLinkResource,
PrepareEmailAddressVerificationParams,
StartEmailLinkFlowParams,
StartEmailAddressLinkFlowParams,
StartEnterpriseSSOLinkFlowParams,
VerificationResource,
} from '@clerk/shared/types';
Expand Down Expand Up @@ -51,15 +51,28 @@ export class EmailAddress extends BaseResource implements EmailAddressResource {
});
};

createEmailLinkFlow = (): CreateEmailLinkFlowReturn<StartEmailLinkFlowParams, EmailAddressResource> => {
createEmailLinkFlow = (): CreateEmailLinkFlowReturn<StartEmailAddressLinkFlowParams, EmailAddressResource> => {
const { run, stop } = Poller();

const startEmailLinkFlow = async ({ redirectUrl }: StartEmailLinkFlowParams): Promise<EmailAddressResource> => {
let operation = 0;

const startEmailLinkFlow = async ({
redirectUrl,
onPrepared,
}: StartEmailAddressLinkFlowParams): Promise<EmailAddressResource> => {
const currentOperation = ++operation;
await this.prepareVerification({
strategy: 'email_link',
redirectUrl: redirectUrl,
});
return new Promise((resolve, reject) => {
if (currentOperation !== operation) {
return;
}
onPrepared?.();
if (currentOperation !== operation) {
return;
}
void run(() => {
return this.reload()
.then(res => {
Expand All @@ -75,7 +88,13 @@ export class EmailAddress extends BaseResource implements EmailAddressResource {
});
});
};
return { startEmailLinkFlow, cancelEmailLinkFlow: stop };
return {
startEmailLinkFlow,
cancelEmailLinkFlow: () => {
operation++;
stop();
},
};
};

createEnterpriseSSOLinkFlow = (): CreateEnterpriseSSOLinkFlowReturn<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,67 @@ describe('EmailAddress', () => {
});
});
});

describe('email link readiness', () => {
afterEach(() => vi.restoreAllMocks());

it('signals successful preparation without completing the verification promise', async () => {
const email = new EmailAddress({ id: 'email_123' }, '/me/email_addresses');
const held = createDeferredPromise();
vi.spyOn(email, 'prepareVerification').mockImplementation(async () => {
await held.promise;
return email;
});
const reload = vi.spyOn(email, 'reload').mockImplementation(() => new Promise(() => {}));
const prepared = vi.fn();
const complete = vi.fn();
const flow = email.createEmailLinkFlow();
void flow.startEmailLinkFlow({ redirectUrl: '/verify', onPrepared: prepared }).then(complete);
expect(prepared).not.toHaveBeenCalled();
held.resolve();
await vi.waitFor(() => expect(prepared).toHaveBeenCalledOnce());
expect(reload).toHaveBeenCalledOnce();
expect(complete).not.toHaveBeenCalled();
flow.cancelEmailLinkFlow();
});

it.each(['during preparation', 'from readiness'] as const)(
'does not start polling when cancelled %s',
async phase => {
const email = new EmailAddress({ id: 'email_123' }, '/me/email_addresses');
const held = createDeferredPromise();
vi.spyOn(email, 'prepareVerification').mockImplementation(async () => {
await held.promise;
return email;
});
const reload = vi.spyOn(email, 'reload');
const flow = email.createEmailLinkFlow();
const prepared = vi.fn(() => flow.cancelEmailLinkFlow());
void flow.startEmailLinkFlow({ redirectUrl: '/verify', onPrepared: prepared });
if (phase === 'during preparation') {
flow.cancelEmailLinkFlow();
}
held.resolve();
await held.promise;
await Promise.resolve();
await Promise.resolve();
expect(prepared).toHaveBeenCalledTimes(phase === 'during preparation' ? 0 : 1);
expect(reload).not.toHaveBeenCalled();
},
);

it('rejects failed preparation without reporting readiness and allows retry', async () => {
const email = new EmailAddress({ id: 'email_123' }, '/me/email_addresses');
vi.spyOn(email, 'prepareVerification').mockRejectedValueOnce(new Error('send failed')).mockResolvedValue(email);
email.verification.status = 'verified';
vi.spyOn(email, 'reload').mockResolvedValue(email);
const prepared = vi.fn();
const flow = email.createEmailLinkFlow();
await expect(flow.startEmailLinkFlow({ redirectUrl: '/verify', onPrepared: prepared })).rejects.toThrow(
'send failed',
);
expect(prepared).not.toHaveBeenCalled();
await expect(flow.startEmailLinkFlow({ redirectUrl: '/verify', onPrepared: prepared })).resolves.toBe(email);
expect(prepared).toHaveBeenCalledOnce();
});
});
12 changes: 12 additions & 0 deletions packages/mosaic/docs/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ section. The dialog opens once `visible` is true, so it skips the loading card,
dismissal only while the controller has a cancel callback. Return focus with the hook's
`lastKey`, which outlives the run, unlike `pendingKey`.

For a direct action in `ActionMenu`, derive its `progress` from the existing
pending key and the matching reverification controller. Pass `idle` before
activation, `pending` while the request or verification preparation is running,
and `handoff` when the dialog becomes visible. The menu keeps its selected item
and trailing spinner visible while pending, disables its actions, and blocks
dismissal until handoff or settlement. Ordinary failures close the menu and use
the section's existing error. Actions without `progress` still close immediately.
Keep the selected action and its callback present through data refreshes that
change eligibility. Disable other row triggers while the section is pending,
but keep the selected row's trigger available as the dialog's final-focus target.
The menu owns popup presentation only; `usePendingAction` still owns the operation.

A single open boolean earns nothing. A two-state machine with one event is a
boolean spelled long. `useUserProfileEditUsernameController` holds `isOpen` in
`useState` and hands the async part to `useForm`:
Expand Down
15 changes: 14 additions & 1 deletion packages/mosaic/src/components/action-menu/action-menu.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';

Expand Down Expand Up @@ -35,7 +35,20 @@ describe('Mosaic ActionMenu', () => {
await user.click(await screen.findByRole('menuitem', { name: 'Rename' }));

expect(onClick).toHaveBeenCalledOnce();
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
});
it('closes a progress action that settles before pending renders', async () => {
const onClick = vi.fn();
const user = userEvent.setup();
renderMenu([{ label: 'Make default', progress: 'idle', onClick }]);

await user.click(screen.getByRole('button', { name: 'Manage passkey' }));
await user.click(await screen.findByRole('menuitem', { name: 'Make default' }));

expect(onClick).toHaveBeenCalledOnce();
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
});

it('keeps an eligible action visible but inert until its handler exists', async () => {
const onClick = vi.fn();
const user = userEvent.setup();
Expand Down
49 changes: 44 additions & 5 deletions packages/mosaic/src/components/action-menu/action-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Ref } from 'react';
import { type Ref, useState } from 'react';

import { useSpinDelay } from '../../hooks/use-spin-delay';
import type { IconName } from '../../icons/registry';
import { Icon } from '../icon';
import { Menu } from '../menu';
import { Spinner } from '../spinner';

type ActionMenuActionAppearance = {
label: string;
Expand All @@ -11,7 +13,10 @@ type ActionMenuActionAppearance = {
};

export type ActionMenuAction = ActionMenuActionAppearance &
({ onClick: () => void; disabled?: boolean } | { onClick?: never; disabled: true });
(
| { onClick: () => void; disabled?: boolean; progress?: 'idle' | 'pending' | 'handoff' }
| { onClick?: never; disabled: true; progress?: never }
);

export interface ActionMenuProps {
label: string;
Expand All @@ -21,12 +26,31 @@ export interface ActionMenuProps {
}

export function ActionMenu({ label, actions, triggerRef, disabled }: ActionMenuProps) {
const [open, setOpen] = useState(false);
const [activated, setActivated] = useState(false);
const pending = actions.some(action => action.progress === 'pending');
const handoff = actions.some(action => action.progress === 'handoff');
const showSpinner = useSpinDelay(pending ? true : null);

if (activated && !pending) {
setActivated(false);
setOpen(false);
}

if (actions.length === 0) {
return null;
}

return (
<Menu.Root placement='bottom-end'>
<Menu.Root
placement='bottom-end'
open={open && !handoff}
onOpenChange={nextOpen => {
if (!pending && !handoff) {
setOpen(nextOpen);
}
}}
>
<Menu.Trigger
ref={triggerRef}
aria-label={label}
Expand All @@ -38,15 +62,30 @@ export function ActionMenu({ label, actions, triggerRef, disabled }: ActionMenuP
key={action.label}
color={action.color}
label={action.label}
onClick={action.onClick}
disabled={disabled || action.disabled}
onClick={() => {
if (action.progress !== undefined) {
setActivated(true);
}
action.onClick?.();
}}
closeOnClick={action.progress === undefined}
aria-busy={action.progress === 'pending' || undefined}
disabled={disabled || action.disabled || pending || handoff}
>
{action.icon ? (
<Menu.Media>
<Icon name={action.icon} />
</Menu.Media>
) : null}
<Menu.Label>{action.label}</Menu.Label>
{action.progress === 'pending' && showSpinner ? (
<Spinner
size='sm'
role='progressbar'
aria-hidden={undefined}
aria-label={action.label}
/>
) : null}
</Menu.Item>
))}
</Menu.Popup>
Expand Down
Loading
Loading