Repository navigation
feat(mosaic): add headless sign-in, sign-up, sign-out, and MetaMask buttons #10588
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) { | ||
| 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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| }), | ||
| }; | ||
| } | ||
| 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({ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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> | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.