Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-auth-buttons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';

import { deferred } from '../../../__tests__/async';
import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
import type { SignInButtonProps } from '../sign-in-button';
import { SignInButton } from '../sign-in-button';

function renderButton(props: SignInButtonProps = {}) {
return renderWithClerk(<SignInButton {...props} />);
}

const button = () => screen.findByRole('button', { name: 'Sign in' });

describe('SignInButton', () => {
it('renders its fallback while Clerk is still loading', async () => {
serveFapi();
const client = holdRequests('get', '/v1/client');
const rendering = renderButton({ fallback: <output data-testid='fallback'>Loading</output> });

await waitFor(() => expect(client.requests).toHaveLength(1));
expect(screen.getByTestId('fallback')).toBeInTheDocument();

client.release();
await rendering;
expect(screen.queryByTestId('fallback')).toBeNull();
expect(await button()).toBeVisible();
});

it('redirects to sign-in with its redirect URLs', async () => {
serveFapi();
const { clerk } = await renderButton({
forceRedirectUrl: '/after-sign-in',
fallbackRedirectUrl: '/fallback-sign-in',
signUpForceRedirectUrl: '/after-sign-up',
signUpFallbackRedirectUrl: '/fallback-sign-up',
initialValues: { emailAddress: 'grace@example.com' },
});
const redirectToSignIn = vi.spyOn(clerk, 'redirectToSignIn').mockResolvedValue(undefined);
const openSignIn = vi.spyOn(clerk, 'openSignIn').mockImplementation(() => {});

await userEvent.setup().click(await button());

expect(redirectToSignIn).toHaveBeenCalledExactlyOnceWith({
signInForceRedirectUrl: '/after-sign-in',
signInFallbackRedirectUrl: '/fallback-sign-in',
signUpForceRedirectUrl: '/after-sign-up',
signUpFallbackRedirectUrl: '/fallback-sign-up',
initialValues: { emailAddress: 'grace@example.com' },
});
expect(openSignIn).not.toHaveBeenCalled();
});

it('opens sign-in in a modal when mode is modal', async () => {
serveFapi();
const { clerk } = await renderButton({ mode: 'modal', forceRedirectUrl: '/after-sign-in', withSignUp: true });
const openSignIn = vi.spyOn(clerk, 'openSignIn').mockImplementation(() => {});
const redirectToSignIn = vi.spyOn(clerk, 'redirectToSignIn').mockResolvedValue(undefined);

await userEvent.setup().click(await button());

expect(openSignIn).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ forceRedirectUrl: '/after-sign-in', withSignUp: true }),
);
expect(redirectToSignIn).not.toHaveBeenCalled();
});

it('holds the button while the redirect is in flight', async () => {
serveFapi();
const { clerk } = await renderButton();
const redirect = deferred<unknown>();
const redirectToSignIn = vi.spyOn(clerk, 'redirectToSignIn').mockReturnValue(redirect.promise);
const user = userEvent.setup();
const trigger = await button();

await user.click(trigger);
await waitFor(() => expect(trigger).toBeDisabled());
expect(trigger).toHaveAttribute('data-disabled');
await user.click(trigger);
expect(redirectToSignIn).toHaveBeenCalledTimes(1);

redirect.resolve(undefined);
await waitFor(() => expect(trigger).toBeEnabled());
});

it('renders an unstyled native button', async () => {
serveFapi();
await renderButton();

const trigger = await button();
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('type', 'button');
expect(trigger).not.toHaveAttribute('class');
});

it('takes its label from children and its element from render', async () => {
serveFapi();
const onClick = vi.fn();
const { clerk } = await renderButton({
render: <span data-testid='trigger' />,
nativeButton: false,
className: 'mine',
children: 'Log in',
onClick,
});
const redirectToSignIn = vi.spyOn(clerk, 'redirectToSignIn').mockResolvedValue(undefined);

const trigger = await screen.findByTestId('trigger');
expect(trigger).toHaveAccessibleName('Log in');
expect(trigger).toHaveAttribute('role', 'button');
expect(trigger).toHaveClass('mine');
await userEvent.setup().click(trigger);

expect(onClick).toHaveBeenCalledTimes(1);
expect(redirectToSignIn).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { usePendingAction } from '../../hooks/use-pending-action';
import type { SignInButtonModel } from './sign-in-button.model';

export type SignInButtonReadyModel = Extract<SignInButtonModel, { status: 'ready' }>;

export function useSignInButtonController({ onSignIn }: SignInButtonReadyModel) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could these four share one button?

The controller, view and wrapper are the same code in all four, just with names swapped. Only the models and labels really differ. One shared button, maybe in src/blocks/, would leave each feature as its model, its label, and a short wrapper. Then the error handling and the pending state get fixed once, and the shared behavior gets tested once.

const action = usePendingAction();

return {
onSignIn: () => void action.run('signIn', onSignIn),
isPending: action.isPending,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const signInButtonMessages = {
label: 'Sign in',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useClerk } from '@clerk/shared/react';
import type { SignInProps } from '@clerk/shared/types';

import { useGetPortalContainer } from '../../primitives/portal';

export type SignInButtonOptions = Pick<
SignInProps,
| 'forceRedirectUrl'
| 'fallbackRedirectUrl'
| 'signUpForceRedirectUrl'
| 'signUpFallbackRedirectUrl'
| 'initialValues'
| 'withSignUp'
| 'oauthFlow'
> & {
/**
* Whether the button navigates to the sign-in page or opens sign-in in a modal.
*
* @default 'redirect'
*/
mode?: 'redirect' | 'modal';
};

export type SignInButtonModel = { status: 'loading' } | { status: 'ready'; onSignIn: () => Promise<unknown> | void };

export function useSignInButtonModel({ mode = 'redirect', ...options }: SignInButtonOptions): SignInButtonModel {
const clerk = useClerk();
const getContainer = useGetPortalContainer();

if (!clerk.loaded) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renders nothing until Clerk loads (description only)

The old buttons render right away. These render nothing (or fallback) until Clerk loads, so the page can shift. Could you add this to the list of differences?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to the list of differences in the description.

return { status: 'loading' };
}

return {
status: 'ready',
onSignIn:
mode === 'modal'
? () => clerk.openSignIn({ ...options, getContainer })
: () =>
clerk.redirectToSignIn({
signInForceRedirectUrl: options.forceRedirectUrl,
signInFallbackRedirectUrl: options.fallbackRedirectUrl,
signUpForceRedirectUrl: options.signUpForceRedirectUrl,
signUpFallbackRedirectUrl: options.signUpFallbackRedirectUrl,
initialValues: options.initialValues,
}),
};
}
81 changes: 81 additions & 0 deletions packages/mosaic/src/features/sign-in-button/sign-in-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use client';

import type { ReactElement, ReactNode } from 'react';

import type { SignInButtonReadyModel } from './sign-in-button.controller';
import { useSignInButtonController } from './sign-in-button.controller';
import type { SignInButtonOptions } from './sign-in-button.model';
import { useSignInButtonModel } from './sign-in-button.model';
import type { SignInButtonElementProps } from './sign-in-button.view';
import { SignInButtonView } from './sign-in-button.view';

/** Everything `<SignInButton />` takes: where sign-in goes, the element it renders, and what stands in while Clerk loads. */
export interface SignInButtonProps extends SignInButtonOptions, SignInButtonElementProps {
/** Rendered while Clerk loads. */
fallback?: ReactNode;
}

/**
* A headless button that starts sign-in. It ships no styles: it renders a plain `<button>` labelled
* "Sign in", `children` replaces the label, and `render` swaps the element for your own. Your
* `onClick` runs alongside starting sign-in. It navigates to the sign-in page, or opens sign-in in a
* modal when `mode` is `'modal'`. It renders `fallback` until Clerk answers.
*
* @example
* ```tsx
* import { SignInButton } from '@clerk/mosaic/features/sign-in-button/sign-in-button';
*
* <SignInButton />
* ```
*
* @example
* `render` takes the element to render, with the button's props merged into it.
* ```tsx
* <SignInButton mode='modal' render={<MyButton variant='outline' />}>Log in</SignInButton>
* ```
*/
export function SignInButton({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The option list is written out three times

Sign-in and sign-up list their seven options in three places. If someone adds an option and misses one spot, it ends up as an attribute on the real <button>. TypeScript won't catch it; you'd only see a React warning in dev. Could we list them once?

fallback,
mode,
forceRedirectUrl,
fallbackRedirectUrl,
signUpForceRedirectUrl,
signUpFallbackRedirectUrl,
initialValues,
withSignUp,
oauthFlow,
...props
}: SignInButtonProps = {}): ReactElement {
const model = useSignInButtonModel({
mode,
forceRedirectUrl,
fallbackRedirectUrl,
signUpForceRedirectUrl,
signUpFallbackRedirectUrl,
initialValues,
withSignUp,
oauthFlow,
});

if (model.status === 'loading') {
return <>{fallback}</>;
}

return (
<ReadySignInButton
{...props}
model={model}
/>
);
}

function ReadySignInButton({ model, ...props }: SignInButtonElementProps & { model: SignInButtonReadyModel }) {
const controller = useSignInButtonController(model);

return (
<SignInButtonView
{...props}
{...controller}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ReactElement } from 'react';

import { useMessages } from '../../localization';
import type { ButtonProps } from '../../primitives/button';
import { Button } from '../../primitives/button';
import { mergeProps } from '../../primitives/utils';

export type SignInButtonElementProps = Omit<ButtonProps, 'ref'>;

export interface SignInButtonViewProps extends SignInButtonElementProps {
onSignIn: () => void;
isPending?: boolean;
}

export function SignInButtonView({
onSignIn,
isPending = false,
disabled = false,
children,
...props
}: SignInButtonViewProps): ReactElement {
const m = useMessages('signInButton');

return (
<Button
{...mergeProps<'button'>({ onClick: onSignIn }, props)}
disabled={disabled || isPending}
>
{children ?? m.label}
</Button>
);
}
Loading
Loading