diff --git a/.changeset/violet-windows-turn.md b/.changeset/violet-windows-turn.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/violet-windows-turn.md @@ -0,0 +1,2 @@ +--- +--- 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 379b2da6ae..f8827a44f3 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 @@ -3,6 +3,7 @@ import { Field, FieldError, GlobalError, Input, Label } from '@clerk/elements/common'; import { Action, + Loading, Provider, ProviderIcon, SafeIdentifier, @@ -17,6 +18,7 @@ import { type ComponentProps, useState } from 'react'; import { H1, H3, P } from '@/components/design'; import { CustomField } from '@/components/form'; +import { Spinner } from '@/components/spinner'; function CustomProvider({ children, @@ -26,15 +28,28 @@ function CustomProvider({ provider: ComponentProps['name']; }) { return ( - - - {children} - + + {isLoading => ( + + + + {isLoading ? ( + <> + Loading... + + ) : ( + children + )} + + + )} + ); } @@ -65,7 +80,7 @@ function Button({ children, ...props }: ComponentProps<'button'>) { function CustomSubmit({ children }: ComponentProps<'button'>) { return ( {children} @@ -91,6 +106,9 @@ export default function SignInPage() {

+
+ {isLoading => Loading: {JSON.stringify(isLoading, null, 2)}} +
@@ -116,7 +134,19 @@ export default function SignInPage() { - Sign in with Email + + + {isLoading => + isLoading ? ( + <> + Loading... + + ) : ( + 'Sign in with Email' + ) + } + + ) : ( setContinueWithEmail(true)}>Continue with Email @@ -170,62 +200,90 @@ export default function SignInPage() { -
- + + {isLoading => ( +
+ + + +

+ Welcome back ! +

+ + - -

- Welcome back ! -

- - - - Verify -
- - -

- Welcome back! We've sent a temporary code to -

- - - - Verify -
- - -

- Welcome back! We've sent a temporary code to -

- - - - Verify -
- - -

Verify your email

- -

- We've sent a verification code to . -

-
-
+ + {isLoading ? ( + <> + Loading... + + ) : ( + 'Verify' + )} + + + + +

+ Welcome back! We've sent a temporary code to +

+ + + + + {isLoading ? ( + <> + Loading... + + ) : ( + 'Verify' + )} + +
+ + +

+ Welcome back! We've sent a temporary code to +

+ + + + + {isLoading ? ( + <> + Loading... + + ) : ( + 'Verify' + )} + +
+ + +

Verify your email

+ +

+ We've sent a verification code to . +

+
+
+ )} + ) { return ( {children} @@ -66,7 +67,19 @@ export default function SignUpPage() { label='Phone Number' name='phoneNumber' /> - Sign Up + + + {isLoading => + isLoading ? ( + <> + Loading... + + ) : ( + 'Sign Up' + ) + } + +
@@ -87,7 +100,19 @@ export default function SignUpPage() { name='phoneNumber' /> - Sign Up + + + {isLoading => + isLoading ? ( + <> + Loading... + + ) : ( + 'Sign Up' + ) + } + + @@ -101,7 +126,19 @@ export default function SignUpPage() { name='code' /> - Verify + + + {isLoading => + isLoading ? ( + <> + Loading... + + ) : ( + 'Verify' + ) + } + + @@ -110,7 +147,19 @@ export default function SignUpPage() { name='code' /> - Verify + + + {isLoading => + isLoading ? ( + <> + Loading... + + ) : ( + 'Verify' + ) + } + + Please check your email for a link to verify your account. diff --git a/packages/elements/examples/nextjs/components/loader.tsx b/packages/elements/examples/nextjs/components/loader.tsx deleted file mode 100644 index 181dee0cae..0000000000 --- a/packages/elements/examples/nextjs/components/loader.tsx +++ /dev/null @@ -1,66 +0,0 @@ -'use client'; -import { useIsLoading_unstable } from '@clerk/elements/sign-in'; -import { motion } from 'framer-motion'; - -const colors = ['#22238f', '#6b45fa', '#ca3286', '#fe2b49', '#fe652d']; - -const containerVariants = { - initial: {}, - animate: { - transition: { - when: 'beforeChildren', - staggerChildren: 0.1, - }, - }, -}; - -const dotVariants = { - initial: {}, - animate: { - height: [20, 40, 20], - transition: { - repeat: Infinity, - }, - }, -}; - -const Loader = ({ count = 5 }) => { - return ( - - {Array(count) - .fill(null) - .map((_, index) => { - const color = colors[index % colors.length]; - - return ( - - ); - })} - - ); -}; - -export function Loading({ children }: { children: React.ReactNode }) { - const [isLoading] = useIsLoading_unstable(); - - return isLoading ? : children; -} diff --git a/packages/elements/examples/nextjs/components/spinner.tsx b/packages/elements/examples/nextjs/components/spinner.tsx new file mode 100644 index 0000000000..9a71eace77 --- /dev/null +++ b/packages/elements/examples/nextjs/components/spinner.tsx @@ -0,0 +1,25 @@ +'use client'; + +export const Spinner = () => ( + + + + +); diff --git a/packages/elements/examples/nextjs/package.json b/packages/elements/examples/nextjs/package.json index 5ddb3bdc69..2186389516 100644 --- a/packages/elements/examples/nextjs/package.json +++ b/packages/elements/examples/nextjs/package.json @@ -5,6 +5,7 @@ "scripts": { "build": "next build", "dev": "next dev", + "dev:debug": "NEXT_PUBLIC_CLERK_ELEMENTS_DEBUG=true next dev", "e2e": "playwright test", "lint": "next lint", "start": "next start" diff --git a/packages/elements/package.json b/packages/elements/package.json index ef156374b2..0a762e9903 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/elements", - "version": "0.1.25", + "version": "0.1.26", "description": "Clerk Elements", "keywords": [ "clerk", diff --git a/packages/elements/src/internals/machines/__tests__/shared.actions.test.ts b/packages/elements/src/internals/machines/__tests__/shared.actions.test.ts new file mode 100644 index 0000000000..ef65ac39e1 --- /dev/null +++ b/packages/elements/src/internals/machines/__tests__/shared.actions.test.ts @@ -0,0 +1,97 @@ +import { sendToLoading } from '../shared.actions'; + +describe('sendToLoading', () => { + let context: any; + let event: any; + let parentSendMock: jest.Mock; + + beforeEach(() => { + context = { + parent: { + send: jest.fn(), + }, + loadingStep: new Error('Not implemented'), + }; + event = { + type: new Error('Not implemented'), + }; + parentSendMock = context.parent.send; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should set loading state to false when event type starts with "xstate.done."', () => { + event.type = 'xstate.done.SOME_EVENT'; + context.loadingStep = 'start'; + + sendToLoading({ context, event }); + + expect(parentSendMock).toHaveBeenCalledWith({ + type: 'LOADING', + isLoading: false, + step: undefined, + strategy: undefined, + }); + }); + + test('should set loading state to false when event type starts with "xstate.error."', () => { + event.type = 'xstate.error.SOME_EVENT'; + context.loadingStep = 'start'; + + sendToLoading({ context, event }); + + expect(parentSendMock).toHaveBeenCalledWith({ + type: 'LOADING', + isLoading: false, + step: undefined, + strategy: undefined, + }); + }); + + test('should set loading state to true with undefined step and defined strategy when context.loadingStep is "strategy" and event.type is "REDIRECT"', () => { + context.loadingStep = 'strategy'; + event.type = 'REDIRECT'; + event.params = { + strategy: 'some-strategy', + }; + + sendToLoading({ context, event }); + + expect(parentSendMock).toHaveBeenCalledWith({ + type: 'LOADING', + isLoading: true, + step: undefined, + strategy: 'some-strategy', + }); + }); + + test('should set loading state to true with "continue" step and undefined strategy when loadingStep is "continue"', () => { + context.loadingStep = 'continue'; + event.type = 'SUBMIT'; + + sendToLoading({ context, event }); + + expect(parentSendMock).toHaveBeenCalledWith({ + type: 'LOADING', + isLoading: true, + step: 'continue', + strategy: undefined, + }); + }); + + test('should set loading state to true with the correct step and undefined strategy when loadingStep is not "strategy" or "continue"', () => { + context.loadingStep = 'some-step'; + event.type = 'SUBMIT'; + + sendToLoading({ context, event }); + + expect(parentSendMock).toHaveBeenCalledWith({ + type: 'LOADING', + isLoading: true, + step: 'some-step', + strategy: undefined, + }); + }); +}); diff --git a/packages/elements/src/internals/machines/shared.actions.ts b/packages/elements/src/internals/machines/shared.actions.ts new file mode 100644 index 0000000000..3db5362a00 --- /dev/null +++ b/packages/elements/src/internals/machines/shared.actions.ts @@ -0,0 +1,90 @@ +import type { SignInStrategy } from '@clerk/types'; + +import type { + SignInStartContext, + SignInStartEvents, + SignInVerificationContext, + SignInVerificationEvents, +} from './sign-in/types'; +import type { + SignUpContinueContext, + SignUpContinueEvents, + SignUpStartContext, + SignUpStartRedirectEvent, + SignUpVerificationContext, + SignUpVerificationEvents, +} from './sign-up/types'; +import type { ThirdPartyMachineContext, ThirdPartyMachineEvent } from './third-party/types'; +import type { BaseRouterLoadingStep } from './types'; + +type SendToLoadingProps = { + context: + | SignInStartContext + | SignInVerificationContext + | ThirdPartyMachineContext + | SignUpStartContext + | SignUpContinueContext + | SignUpVerificationContext; + event: + | SignInStartEvents + | SignInVerificationEvents + | ThirdPartyMachineEvent + | SignUpStartRedirectEvent + | SignUpContinueEvents + | SignUpVerificationEvents; +}; + +export function sendToLoading({ context, event }: SendToLoadingProps): void { + // Unrelated to the `context` of each machine, the step passed to the loading event must use BaseRouterLoadingStep + let step: BaseRouterLoadingStep | undefined; + let strategy: SignInStrategy | undefined; + + // By default the loading state is set to `true` when this function is called + // Only if these events are received, the loading state is set to `false` + // Early return here to avoid unnecessary checks + if (event.type.startsWith('xstate.done.') || event.type.startsWith('xstate.error.')) { + return context.parent.send({ + type: 'LOADING', + isLoading: false, + step: undefined, + strategy: undefined, + }); + } + + // `context.loadingStep: "strategy"` is not a valid BaseRouterLoadingStep (on purpose) so needs to be handled here. This context should be used when `step` should be undefined and `strategy` be defined instead + if (context.loadingStep === 'strategy') { + step = undefined; + + // Third-party machine handling + if (event.type === 'REDIRECT') { + strategy = event.params.strategy; + } + + return context.parent.send({ + type: 'LOADING', + isLoading: true, + step, + strategy, + }); + } else if (context.loadingStep === 'continue') { + step = 'continue'; + strategy = undefined; + + return context.parent.send({ + type: 'LOADING', + isLoading: true, + step, + strategy, + }); + } else { + step = context.loadingStep; + strategy = undefined; + + return context.parent.send({ + type: 'LOADING', + isLoading: true, + step, + strategy, + }); + } +} diff --git a/packages/elements/src/internals/machines/sign-in/machines/router.machine.ts b/packages/elements/src/internals/machines/sign-in/machines/router.machine.ts index 510f24b78e..c08236f0f5 100644 --- a/packages/elements/src/internals/machines/sign-in/machines/router.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/machines/router.machine.ts @@ -135,6 +135,15 @@ export const SignInRouterMachine = setup({ 'ROUTE.UNREGISTER': { actions: stopChild(({ event }) => event.id), }, + LOADING: { + actions: assign(({ event }) => ({ + loading: { + isLoading: event.isLoading, + step: event.step, + strategy: event.strategy, + }, + })), + }, }, states: { Idle: { @@ -144,6 +153,9 @@ export const SignInRouterMachine = setup({ clerk: event.clerk, router: event.router, signUpPath: event.signUpPath || SIGN_UP_DEFAULT_BASE_PATH, + loading: { + isLoading: false, + }, })), target: 'Init', }, diff --git a/packages/elements/src/internals/machines/sign-in/machines/start.machine.ts b/packages/elements/src/internals/machines/sign-in/machines/start.machine.ts index 4e19987a6d..4d666a4a48 100644 --- a/packages/elements/src/internals/machines/sign-in/machines/start.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/machines/start.machine.ts @@ -1,9 +1,10 @@ import type { SignInResource } from '@clerk/types'; import type { ActorRefFrom } from 'xstate'; -import { fromPromise, sendParent, sendTo, setup } from 'xstate'; +import { fromPromise, sendTo, setup } from 'xstate'; import { SIGN_IN_DEFAULT_BASE_PATH } from '~/internals/constants'; import type { FormFields } from '~/internals/machines/form/form.types'; +import { sendToLoading } from '~/internals/machines/shared.actions'; import { type TSignInRouterMachine } from '~/internals/machines/sign-in/machines/router.machine'; import type { SignInStartSchema } from '~/internals/machines/sign-in/types'; import { assertActorEventError } from '~/internals/machines/utils/assert'; @@ -46,6 +47,8 @@ export const SignInStartMachine = setup({ }; }, ), + sendToNext: ({ context }) => context.parent.send({ type: 'NEXT' }), + sendToLoading, }, types: {} as SignInStartSchema, }).createMachine({ @@ -54,6 +57,7 @@ export const SignInStartMachine = setup({ basePath: input.basePath || SIGN_IN_DEFAULT_BASE_PATH, parent: input.parent, formRef: input.form, + loadingStep: 'start', }), initial: 'Pending', states: { @@ -61,11 +65,15 @@ export const SignInStartMachine = setup({ tags: ['state:pending'], description: 'Waiting for user input', on: { - SUBMIT: 'Attempting', + SUBMIT: { + target: 'Attempting', + reenter: true, + }, }, }, Attempting: { tags: ['state:attempting', 'state:loading'], + entry: 'sendToLoading', invoke: { id: 'attempt', src: 'attempt', @@ -74,10 +82,10 @@ export const SignInStartMachine = setup({ fields: context.formRef.getSnapshot().context.fields, }), onDone: { - actions: sendParent({ type: 'NEXT' }), + actions: ['sendToNext', 'sendToLoading'], }, onError: { - actions: 'setFormErrors', + actions: ['setFormErrors', 'sendToLoading'], target: 'Pending', }, }, diff --git a/packages/elements/src/internals/machines/sign-in/machines/verification.machine.ts b/packages/elements/src/internals/machines/sign-in/machines/verification.machine.ts index e690efd508..25b6ee9e45 100644 --- a/packages/elements/src/internals/machines/sign-in/machines/verification.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/machines/verification.machine.ts @@ -11,11 +11,12 @@ import type { SignInSecondFactor, Web3Attempt, } from '@clerk/types'; -import type { ActorRefFrom } from 'xstate'; -import { assign, fromPromise, sendParent, sendTo, setup } from 'xstate'; +import type { ActorRefFrom, DoneActorEvent } from 'xstate'; +import { assign, fromPromise, sendTo, setup } from 'xstate'; import { ClerkElementsRuntimeError } from '~/internals/errors'; import type { FormFields } from '~/internals/machines/form/form.types'; +import { sendToLoading } from '~/internals/machines/shared.actions'; import type { WithParams } from '~/internals/machines/shared.types'; import type { SignInRouterMachine } from '~/internals/machines/sign-in/machines/router.machine'; import type { SignInVerificationSchema } from '~/internals/machines/sign-in/types'; @@ -62,6 +63,9 @@ const SignInVerificationMachine = setup({ }; }, ), + sendToNext: ({ context, event }) => + context.parent.send({ type: 'NEXT', resource: (event as unknown as DoneActorEvent).output }), + sendToLoading, }, types: {} as SignInVerificationSchema, }).createMachine({ @@ -70,6 +74,7 @@ const SignInVerificationMachine = setup({ currentFactor: null, formRef: input.form, parent: input.parent, + loadingStep: 'verifications', }), initial: 'Preparing', entry: 'determineStartingFactor', @@ -95,7 +100,10 @@ const SignInVerificationMachine = setup({ description: 'Waiting for user input', on: { 'NAVIGATE.CHOOSE_STRATEGY': 'ChooseStrategy', - SUBMIT: 'Attempting', + SUBMIT: { + target: 'Attempting', + reenter: true, + }, }, }, ChooseStrategy: { @@ -109,6 +117,7 @@ const SignInVerificationMachine = setup({ }, Attempting: { tags: ['state:attempting', 'state:loading'], + entry: 'sendToLoading', invoke: { id: 'attempt', src: 'attempt', @@ -118,10 +127,10 @@ const SignInVerificationMachine = setup({ fields: context.formRef.getSnapshot().context.fields, }), onDone: { - actions: sendParent(({ event }) => ({ type: 'NEXT', resource: event.output })), + actions: ['sendToNext', 'sendToLoading'], }, onError: { - actions: 'setFormErrors', + actions: ['setFormErrors', 'sendToLoading'], target: 'Pending', }, }, diff --git a/packages/elements/src/internals/machines/sign-in/types/router.types.ts b/packages/elements/src/internals/machines/sign-in/types/router.types.ts index b4efae5b10..d755ab4095 100644 --- a/packages/elements/src/internals/machines/sign-in/types/router.types.ts +++ b/packages/elements/src/internals/machines/sign-in/types/router.types.ts @@ -6,6 +6,7 @@ import type { BaseRouterContext, BaseRouterErrorEvent, BaseRouterInput, + BaseRouterLoadingEvent, BaseRouterNextEvent, BaseRouterPrevEvent, BaseRouterRedirectEvent, @@ -50,6 +51,7 @@ export type SignInRouterForgotPasswordEvent = { type: 'NAVIGATE.FORGOT_PASSWORD' export type SignInRouterErrorEvent = BaseRouterErrorEvent; export type SignInRouterTransferEvent = BaseRouterTransferEvent; export type SignInRouterRedirectEvent = BaseRouterRedirectEvent; +export type SignInRouterLoadingEvent = BaseRouterLoadingEvent<'start' | 'verifications'>; export interface SignInRouterInitEvent extends BaseRouterInput { type: 'INIT'; @@ -78,12 +80,16 @@ export type SignInRouterEvents = | SignInRouterTransferEvent | SignInRouterRouteEvents | SignInRouterRedirectEvent - | SignInVerificationFactorUpdateEvent; + | SignInVerificationFactorUpdateEvent + | SignInRouterLoadingEvent; // ---------------------------------- Context ---------------------------------- // +export type SignInRouterLoadingContext = Omit; + export interface SignInRouterContext extends BaseRouterContext { signUpPath: string; + loading: SignInRouterLoadingContext; } // ---------------------------------- Schema ---------------------------------- // diff --git a/packages/elements/src/internals/machines/sign-in/types/start.types.ts b/packages/elements/src/internals/machines/sign-in/types/start.types.ts index 58c1fe350b..79b557d5f2 100644 --- a/packages/elements/src/internals/machines/sign-in/types/start.types.ts +++ b/packages/elements/src/internals/machines/sign-in/types/start.types.ts @@ -29,6 +29,7 @@ export interface SignInStartContext { error?: Error | ClerkAPIResponseError; formRef: ActorRefFrom; parent: ActorRefFrom; + loadingStep: 'start'; } // ---------------------------------- Schema ---------------------------------- // diff --git a/packages/elements/src/internals/machines/sign-in/types/verification.types.ts b/packages/elements/src/internals/machines/sign-in/types/verification.types.ts index aa13ecd4d5..0e84e694e1 100644 --- a/packages/elements/src/internals/machines/sign-in/types/verification.types.ts +++ b/packages/elements/src/internals/machines/sign-in/types/verification.types.ts @@ -38,6 +38,7 @@ export interface SignInVerificationContext { error?: Error | ClerkAPIResponseError; formRef: ActorRefFrom; parent: ActorRefFrom; + loadingStep: 'verifications'; } // ---------------------------------- Schema ---------------------------------- // diff --git a/packages/elements/src/internals/machines/sign-up/machines/continue.machine.ts b/packages/elements/src/internals/machines/sign-up/machines/continue.machine.ts index 8d4e96d724..6aebec721c 100644 --- a/packages/elements/src/internals/machines/sign-up/machines/continue.machine.ts +++ b/packages/elements/src/internals/machines/sign-up/machines/continue.machine.ts @@ -1,10 +1,11 @@ import { snakeToCamel } from '@clerk/shared'; import type { SignUpResource } from '@clerk/types'; -import type { ActorRefFrom } from 'xstate'; -import { fromPromise, sendParent, setup } from 'xstate'; +import type { ActorRefFrom, DoneActorEvent } from 'xstate'; +import { fromPromise, setup } from 'xstate'; import { SIGN_UP_DEFAULT_BASE_PATH } from '~/internals/constants'; import type { FormDefaultValues, FormFields } from '~/internals/machines/form/form.types'; +import { sendToLoading } from '~/internals/machines/shared.actions'; import type { TSignUpRouterMachine } from '~/internals/machines/sign-up/machines'; import type { SignUpContinueSchema } from '~/internals/machines/sign-up/types'; import { fieldsToSignUpParams } from '~/internals/machines/sign-up/utils'; @@ -56,6 +57,9 @@ export const SignUpContinueMachine = setup({ }); }, unmarkFormAsProgressive: ({ context }) => context.formRef.send({ type: 'UNMARK_AS_PROGRESSIVE' }), + sendToNext: ({ context, event }) => + context.parent.send({ type: 'NEXT', resource: (event as unknown as DoneActorEvent).output }), + sendToLoading, }, types: {} as SignUpContinueSchema, }).createMachine({ @@ -64,6 +68,7 @@ export const SignUpContinueMachine = setup({ basePath: input.basePath || SIGN_UP_DEFAULT_BASE_PATH, formRef: input.form, parent: input.parent, + loadingStep: 'continue', }), entry: 'markFormAsProgressive', onDone: { @@ -75,11 +80,15 @@ export const SignUpContinueMachine = setup({ tags: ['state:pending'], description: 'Waiting for user input', on: { - SUBMIT: 'Attempting', + SUBMIT: { + target: 'Attempting', + reenter: true, + }, }, }, Attempting: { tags: ['state:attempting', 'state:loading'], + entry: 'sendToLoading', invoke: { id: 'attempt', src: 'attempt', @@ -88,10 +97,10 @@ export const SignUpContinueMachine = setup({ fields: context.formRef.getSnapshot().context.fields, }), onDone: { - actions: sendParent(({ event }) => ({ type: 'NEXT', resource: event.output })), + actions: ['sendToNext', 'sendToLoading'], }, onError: { - actions: 'setFormErrors', + actions: ['setFormErrors', 'sendToLoading'], target: 'Pending', }, }, diff --git a/packages/elements/src/internals/machines/sign-up/machines/router.machine.ts b/packages/elements/src/internals/machines/sign-up/machines/router.machine.ts index 5f2f69d798..7d8d849de2 100644 --- a/packages/elements/src/internals/machines/sign-up/machines/router.machine.ts +++ b/packages/elements/src/internals/machines/sign-up/machines/router.machine.ts @@ -151,6 +151,15 @@ export const SignUpRouterMachine = setup({ 'ROUTE.UNREGISTER': { actions: stopChild(({ event }) => event.id), }, + LOADING: { + actions: assign(({ event }) => ({ + loading: { + isLoading: event.isLoading, + step: event.step, + strategy: event.strategy, + }, + })), + }, }, states: { Idle: { @@ -160,6 +169,9 @@ export const SignUpRouterMachine = setup({ clerk: event.clerk, router: event.router, signInPath: event.signInPath || SIGN_IN_DEFAULT_BASE_PATH, + loading: { + isLoading: false, + }, })), target: 'Init', }, diff --git a/packages/elements/src/internals/machines/sign-up/machines/start.machine.ts b/packages/elements/src/internals/machines/sign-up/machines/start.machine.ts index 1af9f6f14e..422f839023 100644 --- a/packages/elements/src/internals/machines/sign-up/machines/start.machine.ts +++ b/packages/elements/src/internals/machines/sign-up/machines/start.machine.ts @@ -1,9 +1,10 @@ import type { SignUpResource } from '@clerk/types'; import type { ActorRefFrom } from 'xstate'; -import { fromPromise, sendParent, sendTo, setup } from 'xstate'; +import { fromPromise, sendTo, setup } from 'xstate'; import { SIGN_UP_DEFAULT_BASE_PATH } from '~/internals/constants'; import type { FormFields } from '~/internals/machines/form/form.types'; +import { sendToLoading } from '~/internals/machines/shared.actions'; import type { TSignUpRouterMachine } from '~/internals/machines/sign-up/machines'; import type { SignUpStartSchema } from '~/internals/machines/sign-up/types'; import { fieldsToSignUpParams } from '~/internals/machines/sign-up/utils'; @@ -35,6 +36,8 @@ export const SignUpStartMachine = setup({ }; }, ), + sendToNext: ({ context }) => context.parent.send({ type: 'NEXT' }), + sendToLoading, }, types: {} as SignUpStartSchema, }).createMachine({ @@ -43,6 +46,7 @@ export const SignUpStartMachine = setup({ basePath: input.basePath || SIGN_UP_DEFAULT_BASE_PATH, formRef: input.form, parent: input.parent, + loadingStep: 'start', }), initial: 'Pending', states: { @@ -50,11 +54,15 @@ export const SignUpStartMachine = setup({ tags: ['state:pending'], description: 'Waiting for user input', on: { - SUBMIT: 'Attempting', + SUBMIT: { + target: 'Attempting', + reenter: true, + }, }, }, Attempting: { tags: ['state:attempting', 'state:loading'], + entry: 'sendToLoading', invoke: { id: 'attemptCreate', src: 'attempt', @@ -63,10 +71,10 @@ export const SignUpStartMachine = setup({ fields: context.formRef.getSnapshot().context.fields, }), onDone: { - actions: sendParent({ type: 'NEXT' }), + actions: ['sendToNext', 'sendToLoading'], }, onError: { - actions: 'setFormErrors', + actions: ['setFormErrors', 'sendToLoading'], target: 'Pending', }, }, diff --git a/packages/elements/src/internals/machines/sign-up/machines/verification.machine.ts b/packages/elements/src/internals/machines/sign-up/machines/verification.machine.ts index 611b117282..d0c419ffe2 100644 --- a/packages/elements/src/internals/machines/sign-up/machines/verification.machine.ts +++ b/packages/elements/src/internals/machines/sign-up/machines/verification.machine.ts @@ -14,9 +14,11 @@ import { and, assign, enqueueActions, fromCallback, fromPromise, raise, sendPare import { MAGIC_LINK_VERIFY_PATH_ROUTE, SIGN_UP_DEFAULT_BASE_PATH } from '~/internals/constants'; import { ClerkElementsError, ClerkElementsRuntimeError } from '~/internals/errors'; +import { sendToLoading } from '~/internals/machines/shared.actions'; import type { TSignUpRouterMachine } from '~/internals/machines/sign-up/machines'; import type { SignUpVerificationContext, + SignUpVerificationEmailLinkFailedEvent, SignUpVerificationEvents, SignUpVerificationSchema, } from '~/internals/machines/sign-up/types'; @@ -139,6 +141,7 @@ export const SignUpVerificationMachine = setup({ }; }, ), + sendToLoading, }, guards: { isComplete: ({ context }) => context.resource.status === 'complete', @@ -173,6 +176,7 @@ export const SignUpVerificationMachine = setup({ resource: input.parent.getSnapshot().context.clerk.client.signUp, formRef: input.form, parent: input.parent, + loadingStep: 'verifications', }), on: { NEXT: [ @@ -233,7 +237,10 @@ export const SignUpVerificationMachine = setup({ }, 'EMAIL_LINK.FAILED': { actions: [ - { type: 'setFormErrors', params: ({ event }) => ({ error: event.error }) }, + { + type: 'setFormErrors', + params: ({ event }: { event: SignUpVerificationEmailLinkFailedEvent }) => ({ error: event.error }), + }, assign({ resource: ({ event }) => event.resource }), ], target: '.Pending', @@ -334,11 +341,15 @@ export const SignUpVerificationMachine = setup({ Pending: { tags: ['state:pending'], on: { - SUBMIT: 'Attempting', + SUBMIT: { + target: 'Attempting', + reenter: true, + }, }, }, Attempting: { tags: ['state:attempting', 'state:loading'], + entry: 'sendToLoading', invoke: { id: 'attemptEmailAddressCodeVerification', src: 'attempt', @@ -350,10 +361,10 @@ export const SignUpVerificationMachine = setup({ }, }), onDone: { - actions: raise(({ event }) => ({ type: 'NEXT', resource: event.output })), + actions: [raise(({ event }) => ({ type: 'NEXT', resource: event.output })), 'sendToLoading'], }, onError: { - actions: 'setFormErrors', + actions: ['setFormErrors', 'sendToLoading'], target: 'Pending', }, }, @@ -397,11 +408,15 @@ export const SignUpVerificationMachine = setup({ Pending: { tags: ['state:pending'], on: { - SUBMIT: 'Attempting', + SUBMIT: { + target: 'Attempting', + reenter: true, + }, }, }, Attempting: { tags: ['state:attempting', 'state:loading'], + entry: 'sendToLoading', invoke: { id: 'attemptPhoneNumberVerification', src: 'attempt', @@ -413,10 +428,10 @@ export const SignUpVerificationMachine = setup({ }, }), onDone: { - actions: raise(({ event }) => ({ type: 'NEXT', resource: event.output })), + actions: [raise(({ event }) => ({ type: 'NEXT', resource: event.output })), 'sendToLoading'], }, onError: { - actions: 'setFormErrors', + actions: ['setFormErrors', 'sendToLoading'], target: 'Pending', }, }, diff --git a/packages/elements/src/internals/machines/sign-up/types/continue.types.ts b/packages/elements/src/internals/machines/sign-up/types/continue.types.ts index 555849c6aa..8f57f45ae6 100644 --- a/packages/elements/src/internals/machines/sign-up/types/continue.types.ts +++ b/packages/elements/src/internals/machines/sign-up/types/continue.types.ts @@ -29,6 +29,7 @@ export interface SignUpContinueContext { error?: Error | ClerkAPIResponseError; formRef: ActorRefFrom; parent: ActorRefFrom; + loadingStep: 'continue'; } // ---------------------------------- Schema ---------------------------------- // diff --git a/packages/elements/src/internals/machines/sign-up/types/router.types.ts b/packages/elements/src/internals/machines/sign-up/types/router.types.ts index 4db8c5e881..252eda1c73 100644 --- a/packages/elements/src/internals/machines/sign-up/types/router.types.ts +++ b/packages/elements/src/internals/machines/sign-up/types/router.types.ts @@ -5,6 +5,7 @@ import type { BaseRouterContext, BaseRouterErrorEvent, BaseRouterInput, + BaseRouterLoadingEvent, BaseRouterNextEvent, BaseRouterPrevEvent, BaseRouterRedirectEvent, @@ -45,6 +46,7 @@ export type SignUpRouterPrevEvent = BaseRouterPrevEvent; export type SignUpRouterErrorEvent = BaseRouterErrorEvent; export type SignUpRouterTransferEvent = BaseRouterTransferEvent; export type SignUpRouterRedirectEvent = BaseRouterRedirectEvent; +export type SignUpRouterLoadingEvent = BaseRouterLoadingEvent<'start' | 'verifications' | 'continue'>; export interface SignUpRouterInitEvent extends BaseRouterInput { type: 'INIT'; @@ -68,7 +70,8 @@ export type SignUpRouterEvents = | SignUpRouterErrorEvent | SignUpRouterTransferEvent | SignUpRouterRedirectEvent - | SignUpRouterRouteEvents; + | SignUpRouterRouteEvents + | SignUpRouterLoadingEvent; // ---------------------------------- Delays ---------------------------------- // @@ -80,8 +83,11 @@ export type SignUpRouterDelays = keyof typeof SignUpRouterDelays; // ---------------------------------- Context ---------------------------------- // +export type SignUpRouterLoadingContext = Omit; + export interface SignUpRouterContext extends BaseRouterContext { signInPath: string; + loading: SignUpRouterLoadingContext; } // ---------------------------------- Schema ---------------------------------- // diff --git a/packages/elements/src/internals/machines/sign-up/types/start.types.ts b/packages/elements/src/internals/machines/sign-up/types/start.types.ts index a689afdb6b..11530f9f13 100644 --- a/packages/elements/src/internals/machines/sign-up/types/start.types.ts +++ b/packages/elements/src/internals/machines/sign-up/types/start.types.ts @@ -40,6 +40,7 @@ export interface SignUpStartContext { error?: Error | ClerkAPIResponseError; formRef: ActorRefFrom; parent: ActorRefFrom; + loadingStep: 'start'; } // ---------------------------------- Schema ---------------------------------- // diff --git a/packages/elements/src/internals/machines/sign-up/types/verification.types.ts b/packages/elements/src/internals/machines/sign-up/types/verification.types.ts index c623597b26..57e967c17c 100644 --- a/packages/elements/src/internals/machines/sign-up/types/verification.types.ts +++ b/packages/elements/src/internals/machines/sign-up/types/verification.types.ts @@ -73,6 +73,7 @@ export interface SignUpVerificationContext { error?: Error | ClerkAPIResponseError; formRef: ActorRefFrom; parent: ActorRefFrom; + loadingStep: 'verifications'; } // ---------------------------------- Schema ---------------------------------- // diff --git a/packages/elements/src/internals/machines/third-party/machine.ts b/packages/elements/src/internals/machines/third-party/machine.ts index 6abe6d3c4d..20382f96e0 100644 --- a/packages/elements/src/internals/machines/third-party/machine.ts +++ b/packages/elements/src/internals/machines/third-party/machine.ts @@ -2,13 +2,14 @@ import type { LoadedClerk } from '@clerk/types'; import { assertEvent, assign, log, sendParent, setup } from 'xstate'; import { SSO_CALLBACK_PATH_ROUTE } from '~/internals/constants'; +import { sendToLoading } from '~/internals/machines/shared.actions'; import { assertActorEventError } from '~/internals/machines/utils/assert'; import { getEnabledThirdPartyProviders } from '~/utils/third-party-strategies'; import { handleRedirectCallback, redirect } from './actors'; import type { ThirdPartyMachineSchema } from './types'; -export const ThirdPartyMachineId: string = 'ThirdParty'; +export const ThirdPartyMachineId = 'ThirdParty'; export type TThirdPartyMachine = typeof ThirdPartyMachine; @@ -35,6 +36,8 @@ export const ThirdPartyMachine = setup({ unassignActiveStrategy: assign({ activeStrategy: null, }), + sendToNext: ({ context }) => context.parent.send({ type: 'NEXT' }), + sendToLoading, }, types: {} as ThirdPartyMachineSchema, }).createMachine({ @@ -45,6 +48,7 @@ export const ThirdPartyMachine = setup({ flow: input.flow, parent: input.parent, thirdPartyProviders: getEnabledThirdPartyProviders(input.environment), + loadingStep: 'strategy', }), initial: 'Idle', states: { @@ -61,8 +65,8 @@ export const ThirdPartyMachine = setup({ Redirecting: { description: 'Redirects to the third-party provider for authentication', tags: ['state:redirect', 'state:loading'], - entry: 'assignActiveStrategy', - exit: 'unassignActiveStrategy', + entry: ['assignActiveStrategy', 'sendToLoading'], + exit: ['unassignActiveStrategy', 'sendToLoading'], invoke: { id: 'redirect', src: 'redirect', @@ -106,7 +110,7 @@ export const ThirdPartyMachine = setup({ }, on: { 'CLERKJS.NAVIGATE.*': { - actions: sendParent({ type: 'NEXT' }), + actions: 'sendToNext', }, }, }, diff --git a/packages/elements/src/internals/machines/third-party/types.ts b/packages/elements/src/internals/machines/third-party/types.ts index b3190ef31f..79d1216532 100644 --- a/packages/elements/src/internals/machines/third-party/types.ts +++ b/packages/elements/src/internals/machines/third-party/types.ts @@ -27,6 +27,7 @@ export interface ThirdPartyMachineContext { flow: Flow; thirdPartyProviders: EnabledThirdPartyProviders; parent: AnyActorRef; // TODO: Fix circular dependency + loadingStep: 'strategy'; } // ================= Input ================= // diff --git a/packages/elements/src/internals/machines/types/router.types.ts b/packages/elements/src/internals/machines/types/router.types.ts index 017cea9618..f2482bcc5e 100644 --- a/packages/elements/src/internals/machines/types/router.types.ts +++ b/packages/elements/src/internals/machines/types/router.types.ts @@ -1,6 +1,13 @@ // ---------------------------------- Events ---------------------------------- // -import type { ClerkResource, LoadedClerk, OAuthStrategy, SamlStrategy, Web3Strategy } from '@clerk/types'; +import type { + ClerkResource, + LoadedClerk, + OAuthStrategy, + SamlStrategy, + SignInStrategy, + Web3Strategy, +} from '@clerk/types'; import type { AnyActorLogic, InputFrom } from 'xstate'; import type { ClerkElementsError } from '~/internals/errors'; @@ -8,11 +15,23 @@ import type { ClerkRouter } from '~/react/router'; // ---------------------------------- Events ---------------------------------- // +export type BaseRouterLoadingStep = 'start' | 'verifications' | 'continue'; + export type BaseRouterNextEvent = { type: 'NEXT'; resource?: T }; export type BaseRouterPrevEvent = { type: 'NAVIGATE.PREVIOUS' }; export type BaseRouterStartEvent = { type: 'NAVIGATE.START' }; export type BaseRouterErrorEvent = { type: 'ERROR'; error: Error }; export type BaseRouterTransferEvent = { type: 'TRANSFER' }; +export type BaseRouterLoadingEvent = ( + | { + step: TSteps | undefined; + strategy?: never; + } + | { + step?: never; + strategy: SignInStrategy | undefined; + } +) & { type: 'LOADING'; isLoading: boolean }; export type BaseRouterRouteRegisterEvent = { type: 'ROUTE.REGISTER'; diff --git a/packages/elements/src/react/common/providers.tsx b/packages/elements/src/react/common/providers.tsx index 6cb8c1b753..56f89f831e 100644 --- a/packages/elements/src/react/common/providers.tsx +++ b/packages/elements/src/react/common/providers.tsx @@ -22,7 +22,7 @@ export const useSocialProviderContext = () => { return ctx; }; -export interface ProviderProps extends React.HTMLAttributes { +export interface ProviderProps extends React.ButtonHTMLAttributes { asChild?: boolean; provider: UseThirdPartyProviderReturn | undefined | null; } diff --git a/packages/elements/src/react/hooks/use-loading.hook.ts b/packages/elements/src/react/hooks/use-loading.hook.ts new file mode 100644 index 0000000000..6ccd9b7053 --- /dev/null +++ b/packages/elements/src/react/hooks/use-loading.hook.ts @@ -0,0 +1,42 @@ +import { useSelector } from '@xstate/react'; +import type { ActorRefFrom, SnapshotFrom } from 'xstate'; + +import type { TSignInRouterMachine } from '~/internals/machines/sign-in/machines'; +import type { SignInRouterLoadingContext } from '~/internals/machines/sign-in/types'; +import type { TSignUpRouterMachine } from '~/internals/machines/sign-up/machines'; +import type { SignUpRouterLoadingContext } from '~/internals/machines/sign-up/types'; + +type ActorSignIn = ActorRefFrom; +type ActorSignUp = ActorRefFrom; + +type LoadingContext = T extends ActorSignIn ? SignInRouterLoadingContext : SignUpRouterLoadingContext; +type UseLoadingReturn = [ + isLoading: boolean, + { step: LoadingContext['step']; strategy: LoadingContext['strategy'] }, +]; + +const selectLoading = | SnapshotFrom>( + snapshot: T, +) => snapshot?.context?.loading; +const compareLoadingValue = (prev: T, next: T) => + prev?.isLoading === next?.isLoading; + +/** + * Generic hook to check the loading state inside the context of a machine. Should only be used with `SignInRouterCtx` or `SignUpRouterCtx`. + * + * @param actor - The actor reference of the machine + * + * @example + * const ref = SignInRouterCtx.useActorRef(); + * + * useLoading(ref); + */ +export function useLoading(actor: TActor): UseLoadingReturn { + const loadingCtx = useSelector(actor, selectLoading, compareLoadingValue) as LoadingContext; + + if (!loadingCtx) { + return [false, { step: undefined, strategy: undefined }]; + } + + return [loadingCtx.isLoading, { step: loadingCtx.step, strategy: loadingCtx.strategy }]; +} diff --git a/packages/elements/src/react/sign-in/choose-strategy.tsx b/packages/elements/src/react/sign-in/choose-strategy.tsx index 45e13c87a8..e0c4ba8119 100644 --- a/packages/elements/src/react/sign-in/choose-strategy.tsx +++ b/packages/elements/src/react/sign-in/choose-strategy.tsx @@ -9,6 +9,7 @@ import { SignInRouterSystemId } from '~/internals/machines/sign-in/types'; import { useActiveTags } from '../hooks'; import { ActiveTagsMode } from '../hooks/use-active-tags.hook'; +import { createContextForDomValidation } from '../utils/create-context-for-dom-validation'; import { SignInRouterCtx } from './context'; // --------------------------------- HELPERS --------------------------------- @@ -33,11 +34,13 @@ export function factorHasLocalStrategy(factor: SignInFactor | undefined | null): export type SignInChooseStrategyProps = WithChildrenProp; +export const SignInChooseStrategyCtx = createContextForDomValidation('SignInChooseStrategyCtx'); + export function SignInChooseStrategy({ children }: SignInChooseStrategyProps) { const routerRef = SignInRouterCtx.useActorRef(); const activeState = useActiveTags(routerRef, ['route:first-factor', 'route:choose-strategy'], ActiveTagsMode.all); - return activeState ? children : null; + return activeState ? {children} : null; } const STRATEGY_OPTION_NAME = 'SignInStrategyOption'; diff --git a/packages/elements/src/react/sign-in/hooks/use-loading.hook.ts b/packages/elements/src/react/sign-in/hooks/use-loading.hook.ts deleted file mode 100644 index 7fdabc3c8e..0000000000 --- a/packages/elements/src/react/sign-in/hooks/use-loading.hook.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* eslint-disable react-hooks/rules-of-hooks */ -import { useActiveTags } from '~/react/hooks/use-active-tags.hook'; -import { SignInStartCtx } from '~/react/sign-in/start'; -import { SignInFirstFactorCtx, SignInSecondFactorCtx } from '~/react/sign-in/verifications'; - -/** - * Caution: This hook is unstable and may disappear in the future. - * This is a temporary hook until the actual loading API is explored and implemented. - */ -export const useIsLoading_unstable = () => { - let startLoading = false; - let firstFactorLoading = false; - let secondFactorLoading = false; - - const startRef = SignInStartCtx.useActorRef(true); - if (startRef) { - startLoading = useActiveTags(startRef, 'state:loading'); - } - - const firstFactorRef = SignInFirstFactorCtx.useActorRef(true); - if (firstFactorRef) { - firstFactorLoading = useActiveTags(firstFactorRef, 'state:loading'); - } - - const secondFactorRef = SignInSecondFactorCtx.useActorRef(true); - if (secondFactorRef) { - secondFactorLoading = useActiveTags(secondFactorRef, 'state:loading'); - } - - const isGlobalLoading = startLoading || firstFactorLoading || secondFactorLoading; - - return [isGlobalLoading, { start: startLoading, firstFactor: firstFactorLoading, secondFactor: secondFactorLoading }]; -}; diff --git a/packages/elements/src/react/sign-in/index.ts b/packages/elements/src/react/sign-in/index.ts index dbc70f3772..1797e09740 100644 --- a/packages/elements/src/react/sign-in/index.ts +++ b/packages/elements/src/react/sign-in/index.ts @@ -15,10 +15,9 @@ export { SignInStrategy as Strategy, } from './verifications'; +export { Loading } from './loading'; export { SignInSafeIdentifier as SafeIdentifier, SignInSalutation as Salutation } from './identifiers'; -export { useIsLoading_unstable } from './hooks/use-loading.hook'; - /** @internal Internal use only */ export const useSignInActorRef_internal = SignInRouterCtx.useActorRef; diff --git a/packages/elements/src/react/sign-in/loading.tsx b/packages/elements/src/react/sign-in/loading.tsx new file mode 100644 index 0000000000..c0b43348ca --- /dev/null +++ b/packages/elements/src/react/sign-in/loading.tsx @@ -0,0 +1,124 @@ +import type { OAuthProvider, SamlStrategy } from '@clerk/types'; +import type * as React from 'react'; + +import { ClerkElementsRuntimeError } from '~/internals/errors'; +import { ActiveTagsMode, useActiveTags } from '~/react/hooks/use-active-tags.hook'; +import { mapScopeToStrategy } from '~/react/utils/map-scope-to-strategy'; + +import { useLoading } from '../hooks/use-loading.hook'; +import { SignInChooseStrategyCtx } from './choose-strategy'; +import { SignInRouterCtx } from './context'; +import { SignInStartCtx } from './start'; +import type { SignInStep } from './step'; +import { SignInFirstFactorCtx, SignInSecondFactorCtx } from './verifications'; + +type Strategy = OAuthProvider | SamlStrategy | 'metamask'; +type LoadingScope = 'global' | `step:${SignInStep}` | `provider:${Strategy}`; + +type LoadingProps = { + scope?: LoadingScope; + children: (isLoading: boolean) => React.ReactNode; +}; + +/** + * Access the loading state of a chosen scope. Scope can refer to a step, a provider, or the global loading state. The global loading state is `true` when any of the other scopes are loading. + * + * @param scope - Optional. Specify which loading state to access. Can be a step, a provider, or the global loading state. If `` is used outside a `` it will default to `"global"`. If used inside a `` it will default to the current step. + * @param {Function} children - A render prop function that receives `isLoading` as an argument. `isLoading` is a boolean that indicates if the current scope is loading or not. + * + * @example + * + * + * {(isLoading) => isLoading && "Global loading..."} + * + * + * + * @example + * + * + * + * {(isLoading) => isLoading ? "Start is loading..." : "Submit"} + * + * + * + * + * @example + * + * + * {(isLoading) => ( + * + * {isLoading ? "Loading..." : "Continue with Google"} + * + * )} + * + * + */ +export function Loading({ children, scope }: LoadingProps) { + const routerRef = SignInRouterCtx.useActorRef(true); + + if (!routerRef) { + throw new ClerkElementsRuntimeError(` must be used within a component.`); + } + + let computedScope: LoadingScope; + + // Figure out if the component is inside a `` component + const startCtx = SignInStartCtx.useActorRef(true); + const firstFactorCtx = SignInFirstFactorCtx.useActorRef(true); + const secondFactorCtx = SignInSecondFactorCtx.useActorRef(true); + const chooseStrategyCtx = SignInChooseStrategyCtx.useDomValidation(true); + + // A user can explicitly define the scope, otherwise we'll try to infer it from the surrounding context + if (scope) { + computedScope = scope; + } else { + let inferredScope: LoadingScope; + + if (startCtx) { + inferredScope = 'step:start'; + } else if (firstFactorCtx || secondFactorCtx) { + inferredScope = 'step:verifications'; + } else if (chooseStrategyCtx) { + inferredScope = 'step:choose-strategy'; + } else { + inferredScope = 'global'; + } + + computedScope = inferredScope; + } + + // Access loading state of the router from its context + const [isLoading, { step: loadingStep, strategy }] = useLoading(routerRef); + + const isChooseStrategyStep = useActiveTags( + routerRef, + ['route:first-factor', 'route:choose-strategy'], + ActiveTagsMode.all, + ); + // Determine loading states based on the step + const isStartLoading = isLoading && loadingStep === 'start'; + const isVerificationsLoading = isLoading && loadingStep === 'verifications'; + const isChooseStrategyLoading = isLoading && isChooseStrategyStep; + const isStrategyLoading = isLoading && loadingStep === undefined && strategy !== undefined; + + let returnValue: boolean; + + if (computedScope === 'global') { + returnValue = isLoading; + } else if (computedScope === 'step:start') { + returnValue = isStartLoading; + } else if (computedScope === 'step:verifications') { + returnValue = isVerificationsLoading; + } else if (computedScope === 'step:choose-strategy') { + returnValue = isChooseStrategyLoading; + } else if (computedScope.startsWith('provider:')) { + const computedStrategy = mapScopeToStrategy(computedScope); + returnValue = isStrategyLoading && strategy === computedStrategy; + } else { + throw new ClerkElementsRuntimeError(`Invalid scope "${computedScope}" used for `); + } + + return children(returnValue); +} + +Loading.displayName = 'SignInLoading'; diff --git a/packages/elements/src/react/sign-in/step.tsx b/packages/elements/src/react/sign-in/step.tsx index 575855c31a..c040d58e9c 100644 --- a/packages/elements/src/react/sign-in/step.tsx +++ b/packages/elements/src/react/sign-in/step.tsx @@ -4,7 +4,7 @@ import { SignInChooseStrategy, type SignInChooseStrategyProps } from './choose-s import { SignInStart, type SignInStartProps } from './start'; import { SignInVerifications, type SignInVerificationsProps } from './verifications'; -type SignInStep = 'start' | 'verifications' | 'choose-strategy'; +export type SignInStep = 'start' | 'verifications' | 'choose-strategy'; type StepWithProps = { name: N } & T; export type SignInStepProps = diff --git a/packages/elements/src/react/sign-up/index.ts b/packages/elements/src/react/sign-up/index.ts index d40a3e912c..b76a4300ec 100644 --- a/packages/elements/src/react/sign-up/index.ts +++ b/packages/elements/src/react/sign-up/index.ts @@ -9,6 +9,8 @@ export { SignUpAction as Action } from './action'; export { SignUpStrategy as Strategy } from './verifications'; export { SignUpProvider as Provider, SignUpProviderIcon as ProviderIcon } from './providers'; +export { Loading } from './loading'; + /** @internal Internal use only */ export const useSignUpActorRef_internal = SignUpRouterCtx.useActorRef; diff --git a/packages/elements/src/react/sign-up/loading.tsx b/packages/elements/src/react/sign-up/loading.tsx new file mode 100644 index 0000000000..a96722e0cf --- /dev/null +++ b/packages/elements/src/react/sign-up/loading.tsx @@ -0,0 +1,116 @@ +import type { OAuthProvider, SamlStrategy } from '@clerk/types'; +import type * as React from 'react'; + +import { ClerkElementsRuntimeError } from '~/internals/errors'; +import { mapScopeToStrategy } from '~/react/utils/map-scope-to-strategy'; + +import { useLoading } from '../hooks/use-loading.hook'; +import { SignUpRouterCtx } from './context'; +import { SignUpContinueCtx } from './continue'; +import { SignUpStartCtx } from './start'; +import type { SignUpStep } from './step'; +import { SignUpVerificationCtx } from './verifications'; + +type Strategy = OAuthProvider | SamlStrategy | 'metamask'; +type LoadingScope = 'global' | `step:${SignUpStep}` | `provider:${Strategy}`; + +type LoadingProps = { + scope?: LoadingScope; + children: (isLoading: boolean) => React.ReactNode; +}; + +/** + * Access the loading state of a chosen scope. Scope can refer to a step, a provider, or the global loading state. The global loading state is `true` when any of the other scopes are loading. + * + * @param scope - Optional. Specify which loading state to access. Can be a step, a provider, or the global loading state. If `` is used outside a `` it will default to `"global"`. If used inside a `` it will default to the current step. + * @param {Function} children - A render prop function that receives `isLoading` as an argument. `isLoading` is a boolean that indicates if the current scope is loading or not. + * + * @example + * + * + * {(isLoading) => isLoading && "Global loading..."} + * + * + * + * @example + * + * + * + * {(isLoading) => isLoading ? "Start is loading..." : "Submit"} + * + * + * + * + * @example + * + * + * {(isLoading) => ( + * + * {isLoading ? "Loading..." : "Continue with Google"} + * + * )} + * + * + */ +export function Loading({ children, scope }: LoadingProps) { + const routerRef = SignUpRouterCtx.useActorRef(true); + + if (!routerRef) { + throw new ClerkElementsRuntimeError(` must be used within a component.`); + } + + let computedScope: LoadingScope; + + // Figure out if the component is inside a `` component + const startCtx = SignUpStartCtx.useActorRef(true); + const continueCtx = SignUpContinueCtx.useActorRef(true); + const verificationsCtx = SignUpVerificationCtx.useActorRef(true); + + if (scope) { + computedScope = scope; + } else { + let inferredScope: LoadingScope; + + if (startCtx) { + inferredScope = `step:start`; + } else if (continueCtx) { + inferredScope = `step:continue`; + } else if (verificationsCtx) { + inferredScope = `step:verifications`; + } else { + inferredScope = `global`; + } + + computedScope = inferredScope; + } + + // Access loading state of the router from its context + const [isLoading, { step: loadingStep, strategy }] = useLoading(routerRef); + + // Determine loading states based on the step + const isStartLoading = isLoading && loadingStep === 'start'; + const isVerificationsLoading = isLoading && loadingStep === 'verifications'; + const isContinueLoading = isLoading && loadingStep === 'continue'; + const isStrategyLoading = isLoading && loadingStep === undefined && strategy !== undefined; + + let returnValue: boolean; + + if (computedScope === 'global') { + returnValue = isLoading; + } else if (computedScope === 'step:start') { + returnValue = isStartLoading; + } else if (computedScope === 'step:verifications') { + returnValue = isVerificationsLoading; + } else if (computedScope === 'step:continue') { + returnValue = isContinueLoading; + } else if (computedScope.startsWith('provider:')) { + const computedStrategy = mapScopeToStrategy(computedScope); + returnValue = isStrategyLoading && strategy === computedStrategy; + } else { + throw new ClerkElementsRuntimeError(`Invalid scope "${computedScope}" used for `); + } + + return children(returnValue); +} + +Loading.displayName = 'SignUpLoading'; diff --git a/packages/elements/src/react/sign-up/step.tsx b/packages/elements/src/react/sign-up/step.tsx index a3a8c26abd..5f08d1b983 100644 --- a/packages/elements/src/react/sign-up/step.tsx +++ b/packages/elements/src/react/sign-up/step.tsx @@ -4,7 +4,7 @@ import { SignUpContinue, type SignUpContinueProps } from './continue'; import { SignUpStart, type SignUpStartProps } from './start'; import { SignUpVerifications, type SignUpVerificationsProps } from './verifications'; -type SignUpStep = 'start' | 'continue' | 'verifications'; +export type SignUpStep = 'start' | 'continue' | 'verifications'; type StepWithProps = { name: N } & T; export type SignUpStepProps = diff --git a/packages/elements/src/react/utils/__tests__/map-scope-to-strategy.test.ts b/packages/elements/src/react/utils/__tests__/map-scope-to-strategy.test.ts new file mode 100644 index 0000000000..8662360042 --- /dev/null +++ b/packages/elements/src/react/utils/__tests__/map-scope-to-strategy.test.ts @@ -0,0 +1,21 @@ +import { mapScopeToStrategy } from '../map-scope-to-strategy'; + +describe('mapScopeToStrategy', () => { + test('should return web3_metamask_signature for scope "provider:metamask"', () => { + const scope = 'provider:metamask'; + const strategy = mapScopeToStrategy(scope); + expect(strategy).toBe('web3_metamask_signature'); + }); + + test('should return saml for scope "provider:saml"', () => { + const scope = 'provider:saml'; + const strategy = mapScopeToStrategy(scope); + expect(strategy).toBe('saml'); + }); + + test('should return oauth_{provider} for other scopes', () => { + const scope = 'provider:google'; + const strategy = mapScopeToStrategy(scope); + expect(strategy).toBe('oauth_google'); + }); +}); diff --git a/packages/elements/src/react/utils/create-context-for-dom-validation.ts b/packages/elements/src/react/utils/create-context-for-dom-validation.ts new file mode 100644 index 0000000000..344498861c --- /dev/null +++ b/packages/elements/src/react/utils/create-context-for-dom-validation.ts @@ -0,0 +1,38 @@ +import * as React from 'react'; + +/** + * Use this context helper to detect whether a component has a particular parent higher up in the DOM or not. + */ +export function createContextForDomValidation(displayName: string) { + const ReactContext = React.createContext(false); + const OriginalProvider = ReactContext.Provider; + + function Provider({ children }: WithChildrenProp) { + return React.createElement( + OriginalProvider, + { + value: true, + }, + children, + ); + } + + Provider.displayName = displayName; + + function useContext(allowMissingContext: boolean = false) { + const context = React.useContext(ReactContext); + + if (!allowMissingContext && !context) { + throw new Error( + `You used a hook from "${Provider.displayName}" but it's not inside a <${Provider.displayName}.Provider> component.`, + ); + } + + return context; + } + + return { + Provider, + useDomValidation: useContext, + }; +} diff --git a/packages/elements/src/react/utils/map-scope-to-strategy.ts b/packages/elements/src/react/utils/map-scope-to-strategy.ts new file mode 100644 index 0000000000..5669251034 --- /dev/null +++ b/packages/elements/src/react/utils/map-scope-to-strategy.ts @@ -0,0 +1,17 @@ +import type { OAuthProvider, SamlStrategy, SignInStrategy } from '@clerk/types'; + +type Strategy = OAuthProvider | SamlStrategy | 'metamask'; + +export function mapScopeToStrategy(scope: T): SignInStrategy { + if (scope === 'provider:metamask') { + return 'web3_metamask_signature'; + } + + if (scope === 'provider:saml') { + return 'saml'; + } + + const scopeWithoutPrefix = scope.replace('provider:', '') as OAuthProvider; + + return `oauth_${scopeWithoutPrefix}`; +}