From 124d9f35a99d11583e2f0ea4717ad89edf0ad3e5 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 3 Jan 2024 15:06:55 -0500 Subject: [PATCH] refactor(elements): Use nested states (#2471) * refactor(elements): Use nested states * chore(elements): Change ClerkHostRouter to ClerkRouter * chore(elements): Update contexts --- .changeset/fast-hornets-report.md | 2 + .../src/internals/machines/sign-in.actors.ts | 7 +- .../src/internals/machines/sign-in.context.ts | 29 +- .../src/internals/machines/sign-in.machine.ts | 320 +++++++++++------- packages/elements/src/sign-in/index.tsx | 16 +- 5 files changed, 217 insertions(+), 157 deletions(-) create mode 100644 .changeset/fast-hornets-report.md diff --git a/.changeset/fast-hornets-report.md b/.changeset/fast-hornets-report.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/fast-hornets-report.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/elements/src/internals/machines/sign-in.actors.ts b/packages/elements/src/internals/machines/sign-in.actors.ts index 8d13c25b3a2..d0f499c5759 100644 --- a/packages/elements/src/internals/machines/sign-in.actors.ts +++ b/packages/elements/src/internals/machines/sign-in.actors.ts @@ -2,6 +2,7 @@ import type { AttemptFirstFactorParams, AttemptSecondFactorParams, AuthenticateWithRedirectParams, + EnvironmentResource, HandleOAuthCallbackParams, HandleSamlCallbackParams, PrepareFirstFactorParams, @@ -37,14 +38,16 @@ export const createSignIn = fromPromise ->(async ({ input: { clerk, strategy } }) => { +>(async ({ input: { clerk, environment, strategy } }) => { + assertIsDefined(environment); assertIsDefined(strategy); return clerk.client.signIn.authenticateWithRedirect({ strategy, - redirectUrl: `${clerk.__unstable__environment.displayConfig.signInUrl}/sso-callback`, + redirectUrl: `${environment.displayConfig.signInUrl}/sso-callback`, redirectUrlComplete: clerk.buildAfterSignInUrl(), }); }); diff --git a/packages/elements/src/internals/machines/sign-in.context.ts b/packages/elements/src/internals/machines/sign-in.context.ts index b6e50663d57..c52c28948cf 100644 --- a/packages/elements/src/internals/machines/sign-in.context.ts +++ b/packages/elements/src/internals/machines/sign-in.context.ts @@ -1,9 +1,13 @@ import type { OAuthStrategy, Web3Strategy } from '@clerk/types'; import { createActorContext } from '@xstate/react'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import type { SnapshotFrom } from 'xstate'; -import { isAuthenticatableOauthStrategy, isWeb3Strategy } from '../../utils/third-party-strategies'; +import { + getEnabledThirdPartyProviders, + isAuthenticatableOauthStrategy, + isWeb3Strategy, +} from '../../utils/third-party-strategies'; import { SignInMachine } from './sign-in.machine'; import type { FieldDetails } from './sign-in.types'; @@ -29,7 +33,7 @@ const fieldHasValueSelector = (type: string | undefined) => (state: SnapshotStat * Selects a field-specific error, if it exists */ const fieldErrorSelector = (type: string | undefined) => (state: SnapshotState) => - type ? Boolean(state.context.fields.get(type)?.error) : undefined; + type ? state.context.fields.get(type)?.error : undefined; /** * Selects a global error, if it exists @@ -37,15 +41,9 @@ const fieldErrorSelector = (type: string | undefined) => (state: SnapshotState) const globalErrorSelector = (state: SnapshotState) => state.context.error; /** - * Selects if the environment is loaded + * Selects the clerk environment */ -const hasEnvironmentSelector = (state: SnapshotState) => Boolean(state.context.clerk.__unstable__environment); - -/** - * Selects third-party providers details - */ -const thirdPartyStrategiesSelector = (state: SnapshotState) => - state.context.enabledThirdPartyProviders ? state.context.enabledThirdPartyProviders : undefined; +const clerkEnvironmentSelector = (state: SnapshotState) => state.context.environment; // ================= HOOKS ================= // @@ -80,7 +78,8 @@ export const useForm = () => { */ export const useThirdPartyProviders = () => { const ref = useSignInFlow(); - const providers = useSignInFlowSelector(thirdPartyStrategiesSelector); + const env = useSignInFlowSelector(clerkEnvironmentSelector); + const providers = useMemo(() => env && getEnabledThirdPartyProviders(env), [env]); // Register the onSubmit handler for button click const createOnClick = useCallback( @@ -122,13 +121,15 @@ export const useField = ({ type }: Partial>) => { const error = useSignInFlowSelector(fieldErrorSelector(type)); const shouldBeHidden = false; // TODO: Implement clerk-js utils - const validity = error ? 'invalid' : 'valid'; + const hasError = Boolean(error); + const validity = hasError ? 'invalid' : 'valid'; return { hasValue, props: { [`data-${validity}`]: true, 'data-hidden': shouldBeHidden ? true : undefined, + serverInvalid: hasError, tabIndex: shouldBeHidden ? -1 : 0, }, }; @@ -179,7 +180,7 @@ export const useInput = ({ type, value: initialValue }: Partial { const ref = useSignInFlow(); - const hasEnv = useSignInFlowSelector(hasEnvironmentSelector); + const hasEnv = useSignInFlowSelector(clerkEnvironmentSelector); // TODO: Wholesale move this to the machine ? // Wait for the environment to be loaded before sending the callback event diff --git a/packages/elements/src/internals/machines/sign-in.machine.ts b/packages/elements/src/internals/machines/sign-in.machine.ts index 65fa0203c7f..ebf4f26df32 100644 --- a/packages/elements/src/internals/machines/sign-in.machine.ts +++ b/packages/elements/src/internals/machines/sign-in.machine.ts @@ -1,22 +1,22 @@ -import { type ClerkAPIResponseError, isClerkAPIResponseError } from '@clerk/shared/error'; -import type { OAuthStrategy, SignInResource, Web3Strategy } from '@clerk/types'; -import type { DoneActorEvent, DoneStateEvent, ErrorActorEvent } from 'xstate'; -import { assertEvent, assign, setup } from 'xstate'; +import type { ClerkAPIResponseError } from '@clerk/shared/error'; +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import type { EnvironmentResource, OAuthStrategy, SignInResource, Web3Strategy } from '@clerk/types'; +import type { MachineContext } from 'xstate'; +import { and, assertEvent, assign, enqueueActions, log, not, raise, setup } from 'xstate'; -import type { EnabledThirdPartyProviders } from '../../utils/third-party-strategies'; -import { getEnabledThirdPartyProviders } from '../../utils/third-party-strategies'; import type { ClerkRouter } from '../router'; import { waitForClerk } from './shared.actors'; import * as signInActors from './sign-in.actors'; import type { FieldDetails, LoadedClerkWithEnv } from './sign-in.types'; -import { assertActorEventDone, assertActorEventError } from './utils/assert'; -export interface SignInMachineContext { +export interface SignInMachineContext extends MachineContext { clerk: LoadedClerkWithEnv; - enabledThirdPartyProviders?: EnabledThirdPartyProviders; + environment?: EnvironmentResource; error?: Error | ClerkAPIResponseError; fields: Map; - resource?: SignInResource; + loaded: boolean; + mode: 'browser' | 'server'; + resource: SignInResource | null; router: ClerkRouter; } @@ -26,9 +26,6 @@ export interface SignInMachineInput { } export type SignInMachineEvents = - | DoneActorEvent - | ErrorActorEvent - | DoneStateEvent | { type: 'AUTHENTICATE.OAUTH'; strategy: OAuthStrategy } | { type: 'AUTHENTICATE.WEB3'; strategy: Web3Strategy } | { type: 'FIELD.ADD'; field: Pick } @@ -44,36 +41,44 @@ export type SignInMachineEvents = | { type: 'NEXT' } | { type: 'OAUTH.CALLBACK' } | { type: 'RETRY' } - | { type: 'START' } | { type: 'SUBMIT' }; +export type SignInTags = 'start' | 'first-factor' | 'second-factor' | 'complete'; +export interface SignInMachineTypes { + context: SignInMachineContext; + input: SignInMachineInput; + events: SignInMachineEvents; + tags: SignInTags; +} + export const SignInMachine = setup({ actors: { ...signInActors, waitForClerk, }, actions: { - assignResourceToContext: assign({ - resource: ({ event }) => { - assertActorEventDone(event); - return event.output; - }, - }), - - assignErrorMessageToContext: assign({ - error: ({ event }) => { - assertActorEventError(event); - return event.error; - }, - }), - - navigateTo: ({ context }, { path }: { path: string }) => context.router.replace(path), - - clearFields: assign({ - fields: new Map(), - }), + debug: ({ context, event }, params?: Record) => console.dir({ context, event, params }), + navigateTo({ context }, { path }: { path: string }) { + context.router.replace(path); + }, + setAsActive: ({ context }) => { + const beforeEmit = () => { + return context.router.push(context.clerk.buildAfterSignInUrl()); + }; + void context.clerk.setActive({ session: context.resource?.createdSessionId, beforeEmit }); + }, }, guards: { + isServer: ({ context }) => context.mode === 'server', + isBrowser: ({ context }) => context.mode === 'browser', + isClerkLoaded: ({ context }) => context.clerk.loaded, + isClerkEnvironmentLoaded: ({ context }) => Boolean(context.clerk.__unstable__environment), + isSignInComplete: ({ context }) => context?.resource?.status === 'complete', + isLoggedIn: ({ context }) => Boolean(context.clerk.user), + isSingleSessionMode: ({ context }) => Boolean(context.clerk.__unstable__environment?.authConfig.singleSessionMode), + needsFirstFactor: ({ context }) => context.resource?.status === 'needs_first_factor', + needsSecondFactor: ({ context }) => context.resource?.status === 'needs_second_factor', + hasSignInResource: ({ context }) => Boolean(context.resource), hasClerkAPIError: ({ context }) => isClerkAPIResponseError(context.error), hasClerkAPIErrorCode: ({ context }, params?: { code?: string }) => params?.code @@ -82,19 +87,22 @@ export const SignInMachine = setup({ : false : false, }, - types: { - context: {} as SignInMachineContext, - input: {} as SignInMachineInput, - events: {} as SignInMachineEvents, - }, + types: {} as SignInMachineTypes, }).createMachine({ - context: ({ input }) => ({ - clerk: input.clerk, - router: input.router, - currentFactor: null, - fields: new Map(), - }), - initial: 'Init', + id: 'SignIn', + context: ({ input }) => { + console.debug({ mode: input.clerk.mode, loaded: input.clerk.loaded }); + + return { + clerk: input.clerk, + environment: input.clerk.__unstable__environment, + mode: input.clerk.mode, + loaded: input.clerk.loaded, + router: input.router, + resource: null, + fields: new Map(), + }; + }, on: { 'FIELD.ADD': { actions: assign({ @@ -134,7 +142,6 @@ export const SignInMachine = setup({ actions: assign({ fields: ({ context, event }) => { if (!event.field.type) throw new Error('Field type is required'); - if (context.fields.has(event.field.type)) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion context.fields.get(event.field.type)!.error = event.field.error; @@ -144,77 +151,145 @@ export const SignInMachine = setup({ }, }), }, - 'OAUTH.CALLBACK': '.SSOCallbackRunning', + 'OAUTH.CALLBACK': '#SignIn.SSOCallbackRunning', }, + initial: 'DeterminingState', states: { - Init: { + DeterminingState: { + initial: 'Init', + states: { + Init: { + always: [ + { + description: 'If the SignIn resource is empty, invoke the sign-in start flow.', + guard: 'isServer', + target: 'Server', + }, + { + description: 'Wait for the Clerk instance to be ready.', + guard: 'isBrowser', + target: 'Browser', + }, + ], + }, + Server: { + description: 'Determines the state of the sign-in flow on the server. This is a no-op for now.', // TODO: Implement + entry: ['debug', log('Server no-op')], + }, + Browser: { + description: 'Determines the state of the sign-in flow on the browser.', + always: [ + { + description: 'Wait for the Clerk instance to be ready.', + guard: not('isClerkLoaded'), + target: '#SignIn.WaitingForClerk', + }, + { + description: 'If loggedin and single-session, invoke the sign-in start flow with error.', + guard: and(['isLoggedIn', 'isSingleSessionMode']), + actions: assign({ error: () => new Error('Already logged in.') }), + target: '#SignIn.Start', + }, + { + description: 'If the SignIn resource is empty, invoke the sign-in start flow.', + guard: not('hasSignInResource'), + target: '#SignIn.Start', + }, + { + guard: 'isSignInComplete', + target: '#SignIn.Complete', + }, + { + guard: 'needsFirstFactor', + target: '#SignIn.FirstFactor', + }, + { + guard: 'needsSecondFactor', + target: '#SignIn.SecondFactor', + }, + ], + exit: 'debug', + }, + }, + }, + WaitingForClerk: { + description: 'Waits for the Clerk instance to be ready.', invoke: { src: 'waitForClerk', input: ({ context }) => context.clerk, onDone: { - target: 'Start', + target: 'DeterminingState', actions: assign({ // @ts-expect-error -- this is really IsomorphicClerk up to this point clerk: ({ context }) => context.clerk.clerkjs, - enabledThirdPartyProviders: ({ context }) => - getEnabledThirdPartyProviders(context.clerk.__unstable__environment), + environment: ({ context }) => context.clerk.__unstable__environment, + loaded: true, }), }, }, }, Start: { - entry: ({ context }) => console.log('Start entry: ', context), - on: { - 'AUTHENTICATE.OAUTH': 'InitiatingOAuthAuthentication', - // 'AUTHENTICATE.WEB3': 'InitiatingWeb3Authentication', - SUBMIT: 'StartAttempting', - }, - }, - StartAttempting: { - entry: () => console.log('StartAttempting'), - invoke: { - src: 'createSignIn', - input: ({ context }) => ({ - client: context.clerk.client, - fields: context.fields, - }), - onDone: { actions: 'assignResourceToContext' }, - onError: { - target: 'StartFailure', - actions: 'assignErrorMessageToContext', - }, - }, - always: [ - { - guard: ({ context }) => context?.resource?.status === 'complete', - target: 'Complete', + description: 'The intial state of the sign-in flow.', + initial: 'AwaitingInput', + states: { + AwaitingInput: { + description: 'Waiting for user input', + on: { + 'AUTHENTICATE.OAUTH': '#SignIn.InitiatingOAuthAuthentication', + SUBMIT: 'Attempting', + }, }, - { - guard: ({ context }) => context?.resource?.status === 'needs_first_factor', - target: 'FirstFactor', + Attempting: { + invoke: { + id: 'createSignIn', + src: 'createSignIn', + input: ({ context }) => ({ + client: context.clerk.client, + fields: context.fields, + }), + onDone: { + actions: assign({ + resource: ({ event }) => event.output, + }), + target: 'Success', + }, + onError: { + actions: enqueueActions(({ enqueue, event }) => { + if (isClerkAPIResponseError(event.error)) { + for (const error of event.error.errors) { + enqueue(() => console.debug(error)); + + if (error.meta?.paramName) + enqueue( + raise({ + type: 'FIELD.ERROR', + field: { + type: error.meta.paramName, + error: error, + }, + }), + ); + } + } + }), + target: 'AwaitingInput', + }, + }, }, - ], - }, - StartFailure: { - entry: ({ context }) => console.log('StartFailure entry: ', context), - always: [ - { - guard: { type: 'hasClerkAPIErrorCode', params: { code: 'session_exists' } }, - actions: [ + Success: { + always: [ { - type: 'navigateTo', - params: { - path: '/', - }, + actions: 'setAsActive', + guard: 'isSignInComplete', + }, + { + target: '#SignIn.DeterminingState', }, ], }, - { - guard: 'hasClerkAPIError', - target: 'Start', - }, - ], + }, }, + FirstFactor: { always: 'FirstFactorPreparing', }, @@ -230,7 +305,9 @@ export const SignInMachine = setup({ onDone: { target: 'FirstFactor', actions: [ - 'assignResourceToContext', + assign({ + resource: ({ event }) => event.output, + }), { type: 'navigateTo', params: { @@ -241,14 +318,13 @@ export const SignInMachine = setup({ }, onError: { target: 'Start', - actions: ['assignErrorMessageToContext'], + actions: assign({ error: ({ event }) => event.error as Error }), }, }, }, FirstFactorIdle: { on: { SUBMIT: { - // guard: ({ context }) => !!context.resource, target: 'FirstFactorAttempting', }, }, @@ -265,7 +341,9 @@ export const SignInMachine = setup({ onDone: { target: 'FirstFactor', actions: [ - 'assignResourceToContext', + assign({ + resource: ({ event }) => event.output, + }), { type: 'navigateTo', params: { @@ -276,7 +354,7 @@ export const SignInMachine = setup({ }, onError: { target: 'FirstFactorIdle', - actions: 'assignErrorMessageToContext', + actions: assign({ error: ({ event }) => event.error as Error }), }, }, }, @@ -310,11 +388,13 @@ export const SignInMachine = setup({ }), onDone: { target: 'SecondFactorIdle', - actions: ['assignResourceToContext'], + actions: assign({ + resource: ({ event }) => event.output, + }), }, onError: { target: 'SecondFactorIdle', - actions: ['assignErrorMessageToContext'], + actions: assign({ error: ({ event }) => event.error as Error }), }, }, }, @@ -339,7 +419,9 @@ export const SignInMachine = setup({ onDone: { target: 'SecondFactorIdle', actions: [ - 'assignResourceToContext', + assign({ + resource: ({ event }) => event.output, + }), { type: 'navigateTo', params: { @@ -350,7 +432,7 @@ export const SignInMachine = setup({ }, onError: { target: 'SecondFactorIdle', - actions: ['assignErrorMessageToContext'], + actions: assign({ error: ({ event }) => event.error as Error }), }, }, SecondFactorFailure: { @@ -364,7 +446,6 @@ export const SignInMachine = setup({ }, }, SSOCallbackRunning: { - entry: () => console.log('StartAttempting'), invoke: { src: 'handleSSOCallback', input: ({ context }) => ({ @@ -375,10 +456,11 @@ export const SignInMachine = setup({ }, router: context.router, }), - onDone: { actions: 'assignResourceToContext' }, + onDone: { + actions: assign({ resource: ({ event }) => event.output as SignInResource }), + }, onError: { - target: 'StartFailure', - actions: 'assignErrorMessageToContext', + actions: assign({ error: ({ event }) => event.error as Error }), }, }, always: [ @@ -393,7 +475,6 @@ export const SignInMachine = setup({ ], }, InitiatingOAuthAuthentication: { - entry: () => console.log('InitiatingOAuthAuthentication'), invoke: { src: 'authenticateWithRedirect', input: ({ context, event }) => { @@ -401,31 +482,18 @@ export const SignInMachine = setup({ return { clerk: context.clerk, + environment: context.environment, strategy: event.strategy, }; }, onError: { - target: 'StartFailure', - actions: 'assignErrorMessageToContext', + actions: assign({ error: ({ event }) => event.error as Error }), }, }, }, - // InitiatingWeb3Authentication: { - // entry: () => console.log('InitiatingWeb3Authentication'), - // invoke: { - // src: 'authenticateWithMetamask', - // onError: { - // target: 'StartFailure', - // actions: 'assignErrorMessageToContext', - // }, - // }, - // }, Complete: { + entry: 'setAsActive', type: 'final', - entry: ({ context }) => { - const beforeEmit = () => context.router.push(context.clerk.buildAfterSignInUrl()); - void context.clerk.setActive({ session: context.resource?.createdSessionId, beforeEmit }); - }, }, }, }); diff --git a/packages/elements/src/sign-in/index.tsx b/packages/elements/src/sign-in/index.tsx index bd184cba2e5..00f483ba372 100644 --- a/packages/elements/src/sign-in/index.tsx +++ b/packages/elements/src/sign-in/index.tsx @@ -1,11 +1,9 @@ 'use client'; import { useClerk } from '@clerk/clerk-react'; -import { useEffect } from 'react'; import { SignInFlowProvider as SignInFlowContextProvider, - useSignInFlow, useSSOCallbackHandler, } from '../internals/machines/sign-in.context'; import type { LoadedClerkWithEnv } from '../internals/machines/sign-in.types'; @@ -51,20 +49,8 @@ export function SignIn({ children }: { children: React.ReactNode }): JSX.Element ); } -export function SignInStartInner({ children }: WithChildren) { - const ref = useSignInFlow(); - - useEffect(() => ref.send({ type: 'START' }), [ref]); - - return children; -} - export function SignInStart({ children }: WithChildren) { - return ( - - {children} - - ); + return {children}; } export function SignInFactorOne({ children }: WithChildren) {