From 74a9976aad5ad077b9ca82e8559f75939d826e69 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Wed, 24 Jan 2024 14:33:20 -0600 Subject: [PATCH 1/4] feat(elements): Move SignIn components to /sign-in export, remove hardcoded /sign-in path --- .../examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx index 59df2664b3..886804f520 100644 --- a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx +++ b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx @@ -2,7 +2,7 @@ import { GlobalError, Submit } from '@clerk/elements'; import { - SignIn, + Root as SignIn, SignInContinue, SignInFactorOne, SignInFactorTwo, From 1cd5919dc7867136c31f81251a349cf2fedf31c4 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 25 Jan 2024 10:34:31 -0600 Subject: [PATCH 2/4] chore(elements): Remove ClerkLoaded wrapper to allow SSRing of the initial sign in form --- packages/elements/src/react/sign-in/index.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/elements/src/react/sign-in/index.tsx b/packages/elements/src/react/sign-in/index.tsx index a496ffaaac..e9a527b8e1 100644 --- a/packages/elements/src/react/sign-in/index.tsx +++ b/packages/elements/src/react/sign-in/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ClerkLoaded, useClerk } from '@clerk/clerk-react'; +import { useClerk } from '@clerk/clerk-react'; import type { OAuthProvider, SignInStrategy as ClerkSignInStrategy, Web3Provider } from '@clerk/types'; import { Slot } from '@radix-ui/react-slot'; import type { PropsWithChildren } from 'react'; @@ -71,12 +71,9 @@ export function SignIn({ children, path = '/sign-in' }: PropsWithChildren<{ path router={router} basePath={path} > - {/* TODO: Temporary hydration fix */} - - - {children} - - + + {children} + ); } @@ -84,10 +81,11 @@ export function SignIn({ children, path = '/sign-in' }: PropsWithChildren<{ path // ================= SignInStart ================= // export function SignInStart({ children }: PropsWithChildren) { + const router = useClerkRouter(); const state = useSignInStateMatcher(); const actorRef = useSignInFlow(); - return state.matches('Start') ?
{children}
: null; + return state.matches('Start') || router?.match(undefined, true) ?
{children}
: null; } // ================= SignInFactorOne ================= // From 070b52ea9274fa4136d8ce7e95ed07bb62a1f8cb Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 25 Jan 2024 20:10:02 -0600 Subject: [PATCH 3/4] feat(elements): Introduce loading states --- .../app/sign-in/[[...sign-in]]/page.tsx | 16 ++----- packages/elements/package.json | 10 +++++ .../machines/sign-in/sign-in.context.ts | 16 +++++-- .../machines/sign-in/sign-in.machine.ts | 28 ++++++++++-- .../elements/src/react/common/form/index.tsx | 20 +++++---- .../elements/src/react/common/form/otp.tsx | 7 ++- .../third-party-providers/social-provider.tsx | 44 ++++++++++++------- packages/elements/src/react/sign-in/index.tsx | 21 ++++++--- packages/elements/src/sign-in.ts | 1 + 9 files changed, 107 insertions(+), 56 deletions(-) diff --git a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx index 886804f520..89ad0a5663 100644 --- a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx +++ b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx @@ -6,6 +6,7 @@ import { SignInContinue, SignInFactorOne, SignInFactorTwo, + SignInLoading, SignInSocialProvider, SignInStart, SignInStrategy, @@ -23,9 +24,7 @@ export default function SignInPage() {

START

- -
-
-
- {/*
- - */} - - Sign In + + Submitting...}>Sign In +
diff --git a/packages/elements/package.json b/packages/elements/package.json index 8225f95b38..362f354bb4 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -44,6 +44,16 @@ "types": "./dist/sign-in.d.ts", "default": "./dist/sign-in.js" } + }, + "./server": { + "import": { + "types": "./dist/server.d.mts", + "default": "./dist/server.mjs" + }, + "require": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" + } } }, "files": [ diff --git a/packages/elements/src/internals/machines/sign-in/sign-in.context.ts b/packages/elements/src/internals/machines/sign-in/sign-in.context.ts index 3e7cd3b478..9bbdd615e1 100644 --- a/packages/elements/src/internals/machines/sign-in/sign-in.context.ts +++ b/packages/elements/src/internals/machines/sign-in/sign-in.context.ts @@ -144,7 +144,9 @@ export const useSignInThirdPartyProviders = () => { export const useSignInThirdPartyProvider = (provider: OAuthProvider | Web3Provider): UseThirdPartyProviderReturn => { const ref = useSignInFlow(); + const state = useSignInStateMatcher(); const details = useSignInFlowSelector(clerkThirdPartyProviderSelector(provider)); + const strategy = provider === 'metamask' ? ('web3_metamask_signature' as const) : (`oauth_${provider}` as const); const authenticate = useCallback( (event: React.MouseEvent) => { @@ -153,12 +155,16 @@ export const useSignInThirdPartyProvider = (provider: OAuthProvider | Web3Provid event.preventDefault(); if (provider === 'metamask') { - return ref.send({ type: 'AUTHENTICATE.WEB3', strategy: 'web3_metamask_signature' }); + ref.send({ type: 'SET_LOADING_CONTEXT', name: strategy }); + // @ts-expect-error -- TS is not respecting the ternary in the strategy declaration above + return ref.send({ type: 'AUTHENTICATE.WEB3', strategy }); } - return ref.send({ type: 'AUTHENTICATE.OAUTH', strategy: `oauth_${provider}` }); + ref.send({ type: 'SET_LOADING_CONTEXT', name: strategy }); + // @ts-expect-error -- TS is not respecting the ternary in the strategy declaration above + return ref.send({ type: 'AUTHENTICATE.OAUTH', strategy }); }, - [provider, details, ref], + [provider, details, ref, strategy], ); if (!details) { @@ -170,7 +176,9 @@ export const useSignInThirdPartyProvider = (provider: OAuthProvider | Web3Provid events: { authenticate, }, - ...details, + isDisabled: state.hasTag('loading'), + isLoading: state.hasTag('loading') && state.context.loadingContext === strategy, + provider: details, }; }; diff --git a/packages/elements/src/internals/machines/sign-in/sign-in.machine.ts b/packages/elements/src/internals/machines/sign-in/sign-in.machine.ts index 90fba2d5b4..6be2da95ca 100644 --- a/packages/elements/src/internals/machines/sign-in/sign-in.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/sign-in.machine.ts @@ -58,7 +58,8 @@ export type SignInMachineEvents = | { type: 'AUTHENTICATE.WEB3'; strategy: Web3Strategy } | { type: 'FAILURE'; error: Error } | { type: 'OAUTH.CALLBACK' } - | { type: 'SUBMIT' }; + | { type: 'SUBMIT' } + | { type: 'SET_LOADING_CONTEXT'; name: string }; export interface SignInMachineTypes { context: SignInMachineContext; @@ -141,6 +142,14 @@ export const SignInMachine = setup({ }; }, ), + setLoadingContext: assign(({ event }) => { + if (event.type === 'SET_LOADING_CONTEXT') { + return { + loadingContext: event.name, + }; + } + return {}; + }), }, guards: { isCurrentFactorPassword: ({ context }) => context.currentFactor?.strategy === 'password', @@ -188,6 +197,7 @@ export const SignInMachine = setup({ on: { 'CLERKJS.NAVIGATE.*': '.HavingTrouble', FAILURE: '.HavingTrouble', + SET_LOADING_CONTEXT: { actions: ['setLoadingContext'] }, }, states: { Init: { @@ -263,6 +273,11 @@ export const SignInMachine = setup({ guard: 'isSignInComplete', target: 'Complete', }, + { + guard: 'needsIdentifier', + target: 'Start', + reenter: true, + }, { guard: 'needsFirstFactor', target: 'FirstFactor', @@ -283,6 +298,7 @@ export const SignInMachine = setup({ }, }, Attempting: { + tags: ['loading'], invoke: { id: 'createSignIn', src: 'createSignIn', @@ -307,6 +323,7 @@ export const SignInMachine = setup({ }, FirstFactor: { initial: 'DeterminingState', + // entry: ['assignStartingFirstFactor'], entry: [{ type: 'navigateTo', params: { path: '/continue' } }, 'assignStartingFirstFactor'], onDone: [ { @@ -323,7 +340,7 @@ export const SignInMachine = setup({ always: [ { description: 'If the current factor is not password, prepare the factor', - guard: not('isCurrentFactorPassword'), + guard: and([not('isCurrentFactorPassword'), { type: 'isCurrentPath', params: { path: '/continue' } }]), target: 'Preparing', }, { @@ -333,6 +350,7 @@ export const SignInMachine = setup({ ], }, Preparing: { + tags: ['loading'], invoke: { id: 'prepareFirstFactor', src: 'prepareFirstFactor', @@ -367,6 +385,7 @@ export const SignInMachine = setup({ }, }, Attempting: { + tags: ['loading'], invoke: { id: 'attemptFirstFactor', src: 'attemptFirstFactor', @@ -406,7 +425,7 @@ export const SignInMachine = setup({ always: [ { description: 'If the current factor is not TOTP, prepare the factor', - guard: not('isCurrentFactorTOTP'), + guard: and([not('isCurrentFactorTOTP'), { type: 'isCurrentPath', params: { path: '/continue' } }]), target: 'Preparing', reenter: true, }, @@ -418,6 +437,7 @@ export const SignInMachine = setup({ ], }, Preparing: { + tags: ['loading'], invoke: { id: 'prepareSecondFactor', src: 'prepareSecondFactor', @@ -445,6 +465,7 @@ export const SignInMachine = setup({ }, }, Attempting: { + tags: ['loading'], invoke: { id: 'attemptSecondFactor', src: 'attemptSecondFactor', @@ -475,6 +496,7 @@ export const SignInMachine = setup({ }, }, AuthenticatingWithRedirect: { + tags: ['loading'], invoke: { id: 'authenticateWithSignInRedirect', src: 'authenticateWithSignInRedirect', diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index 37ae68b575..8e49ee5f7c 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -13,8 +13,8 @@ import { Label as RadixLabel, Submit, } from '@radix-ui/react-form'; -import type { ComponentProps, ReactNode } from 'react'; -import React, { createContext, useCallback, useContext, useEffect } from 'react'; +import type { ChangeEvent, ComponentProps, FormEvent, ReactNode } from 'react'; +import { createContext, useCallback, useContext, useEffect, useTransition } from 'react'; import type { BaseActorRef } from 'xstate'; import type { ClerkElementsError } from '~/internals/errors/error'; @@ -40,15 +40,16 @@ const useFieldContext = () => useContext(FieldContext); */ const useForm = ({ flowActor }: { flowActor?: BaseActorRef<{ type: 'SUBMIT' }> }) => { const error = useFormSelector(globalErrorsSelector); + const [isPending, startTransition] = useTransition(); const validity = error ? 'invalid' : 'valid'; // Register the onSubmit handler for form submission // TODO: merge user-provided submit handler const onSubmit = useCallback( - (event: React.FormEvent) => { + (event: FormEvent) => { event.preventDefault(); if (flowActor) { - flowActor.send({ type: 'SUBMIT' }); + startTransition(() => flowActor.send({ type: 'SUBMIT' })); } }, [flowActor], @@ -56,6 +57,7 @@ const useForm = ({ flowActor }: { flowActor?: BaseActorRef<{ type: 'SUBMIT' }> } return { props: { + [`data-submitting`]: isPending, [`data-${validity}`]: true, onSubmit, }, @@ -130,7 +132,7 @@ const useInput = ({ name: inputName, value: initialValue, type: inputType, ...pa // Register the onChange handler for field updates to persist to the machine context const onChange = useCallback( - (event: React.ChangeEvent) => { + (event: ChangeEvent) => { onChangeProp?.(event); if (!name) return; ref.send({ type: 'FIELD.UPDATE', field: { name, value: event.target.value } }); @@ -156,7 +158,7 @@ const useInput = ({ name: inputName, value: initialValue, type: inputType, ...pa inputMode: 'numeric', pattern: '[0-9]*', maxLength: 6, - onChange: (event: React.ChangeEvent) => { + onChange: (event: ChangeEvent) => { // Only accept numbers event.currentTarget.value = event.currentTarget.value.replace(/\D+/g, ''); onChange(event); @@ -241,11 +243,11 @@ type FormErrorRenderProps = Pick; type FormErrorProps = Omit & ( | { - children?: (error: FormErrorRenderProps) => React.ReactNode; + children?: (error: FormErrorRenderProps) => ReactNode; code?: string; } | { - children: React.ReactNode; + children: ReactNode; code: string; } ); @@ -303,9 +305,9 @@ export { Field, FieldError, FieldState, Form, GlobalError, Input, Label, Submit export type { FormControlProps, FormErrorProps, - FormGlobalErrorProps, FormErrorRenderProps, FormFieldErrorProps, FormFieldProps, + FormGlobalErrorProps, FormProps, }; diff --git a/packages/elements/src/react/common/form/otp.tsx b/packages/elements/src/react/common/form/otp.tsx index efd9fd0b6b..a08546f30c 100644 --- a/packages/elements/src/react/common/form/otp.tsx +++ b/packages/elements/src/react/common/form/otp.tsx @@ -1,8 +1,7 @@ import type { FormControlProps } from '@radix-ui/react-form'; import { Control as RadixControl } from '@radix-ui/react-form'; -import { Slot } from '@radix-ui/react-slot'; import type { CSSProperties, ReactNode, RefObject } from 'react'; -import { forwardRef, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'; +import { forwardRef, Fragment, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'; export type OTPInputProps = Exclude< FormControlProps, @@ -101,7 +100,7 @@ const OTPInputSegmented = forwardRef {Array.from({ length: maxLength }).map((_, i) => ( - + {render({ value: String(props.value)[i] || '', status: @@ -112,7 +111,7 @@ const OTPInputSegmented = forwardRef + ))} diff --git a/packages/elements/src/react/common/third-party-providers/social-provider.tsx b/packages/elements/src/react/common/third-party-providers/social-provider.tsx index 21b3cbd3f9..30696b17d0 100644 --- a/packages/elements/src/react/common/third-party-providers/social-provider.tsx +++ b/packages/elements/src/react/common/third-party-providers/social-provider.tsx @@ -3,15 +3,20 @@ import { createContext, useContext } from 'react'; import type { ThirdPartyProvider } from '~/utils/third-party-strategies'; -export type UseThirdPartyProviderReturn = - | (ThirdPartyProvider & { - events: { - authenticate: (event: React.MouseEvent) => void; - }; - }) - | null; - -export const SocialProviderContext = createContext(null); +export type UseThirdPartyProviderReturn = { + provider?: ThirdPartyProvider; + events: { + authenticate: (event: React.MouseEvent) => void; + }; + isLoading: boolean; + isDisabled: boolean; +} | null; + +export const SocialProviderContext = createContext<{ + provider: ThirdPartyProvider; + isLoading: boolean; + isDisabled: boolean; +} | null>(null); export const useSocialProviderContext = () => { const ctx = useContext(SocialProviderContext); @@ -22,12 +27,12 @@ export const useSocialProviderContext = () => { return ctx; }; -export interface SocialProviderProps extends React.HTMLAttributes { - asChild?: boolean; - provider: UseThirdPartyProviderReturn | undefined | null; -} +export type SocialProviderProps = React.HTMLAttributes & + UseThirdPartyProviderReturn & { + asChild?: boolean; + }; -export function SocialProvider({ asChild, provider, ...rest }: SocialProviderProps) { +export function SocialProvider({ asChild, provider, events, isLoading, isDisabled, ...rest }: SocialProviderProps) { if (!provider) { return null; } @@ -35,9 +40,12 @@ export function SocialProvider({ asChild, provider, ...rest }: SocialProviderPro const Comp = asChild ? Slot : 'button'; return ( - + @@ -49,7 +57,9 @@ export interface SocialProviderIconProps extends Omit{children} : null; + return state.matches('AuthenticatingWithRedirect') || + state.matches('Start') || + (router?.match(undefined, true) && state.matches('Init')) ? ( +
{children}
+ ) : null; } // ================= SignInFactorOne ================= // export function SignInFactorOne({ children }: PropsWithChildren) { const state = useSignInStateMatcher(); - const actorRef = useSignInFlow(); - return state.matches('FirstFactor') ?
{children}
: null; + return state.matches('FirstFactor') ? children : null; } // ================= SignInFactorTwo ================= // export function SignInFactorTwo({ children }: PropsWithChildren) { const state = useSignInStateMatcher(); - const actorRef = useSignInFlow(); - return state.matches('SecondFactor') ?
{children}
: null; + return state.matches('SecondFactor') ? children : null; } // ================= SignInContinue ================= // @@ -162,14 +164,19 @@ export interface SignInSocialProviderProps extends Omit ); } export const SignInSocialProviderIcon = SocialProviderIcon; + +export function SignInLoading({ children, fallback }: any) { + const state = useSignInStateMatcher(); + return state.hasTag('loading') ? fallback : children; +} diff --git a/packages/elements/src/sign-in.ts b/packages/elements/src/sign-in.ts index c43295ff77..b293401551 100644 --- a/packages/elements/src/sign-in.ts +++ b/packages/elements/src/sign-in.ts @@ -10,6 +10,7 @@ export { SignInSocialProvider, SignInSocialProviderIcon, SignInStrategy, + SignInLoading, } from '~/react/sign-in'; export { useSignInFlow, useSignInFlowSelector } from '~/internals/machines/sign-in/sign-in.context'; From f7f276f7382a9f0b6a336dbf026cc46a5a2870dd Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 26 Jan 2024 16:24:08 -0600 Subject: [PATCH 4/4] Update packages/elements/src/internals/machines/sign-in/sign-in.machine.ts --- .../elements/src/internals/machines/sign-in/sign-in.machine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/elements/src/internals/machines/sign-in/sign-in.machine.ts b/packages/elements/src/internals/machines/sign-in/sign-in.machine.ts index 6be2da95ca..b7929335d5 100644 --- a/packages/elements/src/internals/machines/sign-in/sign-in.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/sign-in.machine.ts @@ -323,7 +323,7 @@ export const SignInMachine = setup({ }, FirstFactor: { initial: 'DeterminingState', - // entry: ['assignStartingFirstFactor'], + entry: ['assignStartingFirstFactor'], entry: [{ type: 'navigateTo', params: { path: '/continue' } }, 'assignStartingFirstFactor'], onDone: [ {