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}`;
+}