From 3d790d5ea347a51ef16557c015c901a9f277effe Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 16 May 2024 12:54:14 +0300 Subject: [PATCH] feat(clerk-js,clerk-react): Replace mount with open for GoogleOneTap (#3379) * feat(clerk-js,clerk-react): Replace mount with open for GoogleOneTap * feat(clerk-js,clerk-react): Add changeset * feat(clerk-js,clerk-react): Reuse initialized GIS instance --- .changeset/warm-pumas-fetch.md | 7 +++ packages/clerk-js/src/core/clerk.ts | 36 +++++-------- packages/clerk-js/src/ui/Components.tsx | 33 ++++++++++-- .../components/GoogleOneTap/one-tap-start.tsx | 50 +++++++++-------- .../ui/contexts/ClerkUIComponentsContext.tsx | 3 +- .../clerk-js/src/ui/lazyModules/providers.tsx | 25 +++++++++ packages/clerk-js/src/ui/portal/index.tsx | 53 ++++++++++++------- packages/clerk-js/src/utils/one-tap.ts | 6 ++- .../react/src/components/uiComponents.tsx | 40 +++++++++++--- packages/react/src/isomorphicClerk.ts | 35 +++++++----- packages/react/src/types.ts | 6 +++ packages/types/src/clerk.ts | 30 +++++------ 12 files changed, 215 insertions(+), 109 deletions(-) create mode 100644 .changeset/warm-pumas-fetch.md diff --git a/.changeset/warm-pumas-fetch.md b/.changeset/warm-pumas-fetch.md new file mode 100644 index 0000000000..18a43bd927 --- /dev/null +++ b/.changeset/warm-pumas-fetch.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +Replace mount with open for GoogleOneTap. New api is `__experimental_openGoogleOneTap`. diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 90a97ab86f..ecbe51f7a0 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -330,6 +330,18 @@ export class Clerk implements ClerkInterface { } }; + public __experimental_openGoogleOneTap = (props?: OneTapProps): void => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls + .ensureMounted({ preloadHint: 'OneTap' }) + .then(controls => controls.openModal('googleOneTap', props || {})); + }; + + public __experimental_closeGoogleOneTap = (): void => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls.ensureMounted().then(controls => controls.closeModal('googleOneTap')); + }; + public openSignIn = (props?: SignInProps): void => { this.assertComponentsReady(this.#componentControls); if (sessionExistsAndSingleSessionModeEnabled(this, this.environment)) { @@ -460,30 +472,6 @@ export class Clerk implements ClerkInterface { ); }; - public __experimental_mountGoogleOneTap = (node: HTMLDivElement, props?: OneTapProps): void => { - this.assertComponentsReady(this.#componentControls); - - void this.#componentControls.ensureMounted({ preloadHint: 'OneTap' }).then(controls => - controls.mountComponent({ - name: 'OneTap', - appearanceKey: 'oneTap', - node, - props, - }), - ); - // TODO-ONETAP: Enable telemetry one feature is ready for public beta - // this.telemetry?.record(eventPrebuiltComponentMounted('GoogleOneTap', props)); - }; - - public __experimental_unmountGoogleOneTap = (node: HTMLDivElement): void => { - this.assertComponentsReady(this.#componentControls); - void this.#componentControls.ensureMounted().then(controls => - controls.unmountComponent({ - node, - }), - ); - }; - public mountSignUp = (node: HTMLDivElement, props?: SignUpProps): void => { this.assertComponentsReady(this.#componentControls); void this.#componentControls.ensureMounted({ preloadHint: 'SignUp' }).then(controls => diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index ef8fb3a316..15678e9de0 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -6,6 +6,7 @@ import type { ClerkOptions, CreateOrganizationProps, EnvironmentResource, + OneTapProps, OrganizationProfileProps, SignInProps, SignUpProps, @@ -32,6 +33,7 @@ import { LazyComponentRenderer, LazyImpersonationFabProvider, LazyModalRenderer, + LazyOneTapRenderer, LazyProviders, } from './lazyModules/providers'; import type { AvailableComponentProps } from './types'; @@ -52,11 +54,15 @@ export type ComponentControls = { node?: HTMLDivElement; props?: unknown; }) => void; - openModal: ( + openModal: < + T extends 'googleOneTap' | 'signIn' | 'signUp' | 'userProfile' | 'organizationProfile' | 'createOrganization', + >( modal: T, props: T extends 'signIn' ? SignInProps : T extends 'signUp' ? SignUpProps : UserProfileProps, ) => void; - closeModal: (modal: 'signIn' | 'signUp' | 'userProfile' | 'organizationProfile' | 'createOrganization') => void; + closeModal: ( + modal: 'googleOneTap' | 'signIn' | 'signUp' | 'userProfile' | 'organizationProfile' | 'createOrganization', + ) => void; // Special case, as the impersonation fab mounts automatically mountImpersonationFab: () => void; }; @@ -78,6 +84,7 @@ interface ComponentsProps { interface ComponentsState { appearance: Appearance | undefined; options: ClerkOptions | undefined; + googleOneTapModal: null | OneTapProps; signInModal: null | SignInProps; signUpModal: null | SignUpProps; userProfileModal: null | UserProfileProps; @@ -153,6 +160,7 @@ const Components = (props: ComponentsProps) => { const [state, setState] = React.useState({ appearance: props.options.appearance, options: props.options, + googleOneTapModal: null, signInModal: null, signUpModal: null, userProfileModal: null, @@ -161,8 +169,15 @@ const Components = (props: ComponentsProps) => { nodes: new Map(), impersonationFab: false, }); - const { signInModal, signUpModal, userProfileModal, organizationProfileModal, createOrganizationModal, nodes } = - state; + const { + googleOneTapModal, + signInModal, + signUpModal, + userProfileModal, + organizationProfileModal, + createOrganizationModal, + nodes, + } = state; const { urlStateParam, clearUrlStateParam, decodedRedirectParams } = useClerkModalStateParams(); @@ -219,6 +234,15 @@ const Components = (props: ComponentsProps) => { props.onComponentsMounted(); }, []); + const mountedOneTapModal = ( + + ); + const mountedSignInModal = ( { ); })} + {googleOneTapModal && mountedOneTapModal} {signInModal && mountedSignInModal} {signUpModal && mountedSignUpModal} {userProfileModal && mountedUserProfileModal} diff --git a/packages/clerk-js/src/ui/components/GoogleOneTap/one-tap-start.tsx b/packages/clerk-js/src/ui/components/GoogleOneTap/one-tap-start.tsx index 750b703c75..715553d4ae 100644 --- a/packages/clerk-js/src/ui/components/GoogleOneTap/one-tap-start.tsx +++ b/packages/clerk-js/src/ui/components/GoogleOneTap/one-tap-start.tsx @@ -53,41 +53,49 @@ function _OneTapStart(): JSX.Element | null { const environmentClientID = environment.displayConfig.googleOneTapClientId; const shouldLoadGIS = !user?.id && !!environmentClientID; + async function initializeGIS() { + const google = await loadGIS(); + google.accounts.id.initialize({ + client_id: environmentClientID!, + callback: oneTapCallback, + itp_support: ctx.itpSupport, + cancel_on_tap_outside: ctx.cancelOnTapOutside, + auto_select: false, + use_fedcm_for_prompt: ctx.fedCmSupport, + }); + return google; + } + /** * Prevent GIS from initializing multiple times */ - useFetch(shouldLoadGIS ? loadGIS : undefined, 'google-identity-services-script', { - onSuccess(google) { - google.accounts.id.initialize({ - client_id: environmentClientID!, - callback: oneTapCallback, - itp_support: ctx.itpSupport, - cancel_on_tap_outside: ctx.cancelOnTapOutside, - auto_select: false, - use_fedcm_for_prompt: ctx.fedCmSupport, - }); - - google.accounts.id.prompt(); - isPromptedRef.current = true; - }, - }); + const { data: initializedGoogle } = useFetch( + shouldLoadGIS ? initializeGIS : undefined, + 'google-identity-services-script', + ); useEffect(() => { - if (window.google && !user?.id && !isPromptedRef.current) { - window.google.accounts.id.prompt(); + if (initializedGoogle && !user?.id && !isPromptedRef.current) { + initializedGoogle.accounts.id.prompt(notification => { + // Close the modal, when the user clicks outside the prompt or cancels + if (notification.getMomentType() === 'skipped') { + // Unmounts the component will cause the useEffect cleanup function from below to be called + clerk.__experimental_closeGoogleOneTap(); + } + }); isPromptedRef.current = true; } - }, [user?.id]); + }, [clerk, initializedGoogle, user?.id]); // Trigger only on mount/unmount. Above we handle the logic for the initial fetch + initialization useEffect(() => { return () => { - if (window.google && isPromptedRef.current) { + if (initializedGoogle && isPromptedRef.current) { isPromptedRef.current = false; - window.google.accounts.id.cancel(); + initializedGoogle.accounts.id.cancel(); } }; - }, []); + }, [initializedGoogle]); return null; } diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index fbeb992a31..bf6f623383 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -510,7 +510,8 @@ export const useGoogleOneTapContext = () => { options, { ...ctx, - redirectUrl: window.location.href, + signInFallbackRedirectUrl: window.location.href, + signUpFallbackRedirectUrl: window.location.href, }, queryParams, ); diff --git a/packages/clerk-js/src/ui/lazyModules/providers.tsx b/packages/clerk-js/src/ui/lazyModules/providers.tsx index 730cd62654..df472b1930 100644 --- a/packages/clerk-js/src/ui/lazyModules/providers.tsx +++ b/packages/clerk-js/src/ui/lazyModules/providers.tsx @@ -2,6 +2,7 @@ import type { Appearance } from '@clerk/types'; import React, { lazy, Suspense } from 'react'; import type { FlowMetadata } from '../elements'; +import { VirtualBodyRootPortal } from '../portal'; import type { ThemableCssProp } from '../styledSystem'; import type { ClerkComponentName } from './components'; import { ClerkComponents } from './components'; @@ -128,3 +129,27 @@ export const LazyImpersonationFabProvider = ( ); }; + +type LazyOneTapRendererProps = React.PropsWithChildren< + { + componentProps: any; + startPath: string; + } & Omit +>; + +export const LazyOneTapRenderer = (props: LazyOneTapRendererProps) => { + return ( + + + + ); +}; diff --git a/packages/clerk-js/src/ui/portal/index.tsx b/packages/clerk-js/src/ui/portal/index.tsx index 93a41a7532..426ade2f22 100644 --- a/packages/clerk-js/src/ui/portal/index.tsx +++ b/packages/clerk-js/src/ui/portal/index.tsx @@ -4,7 +4,6 @@ import ReactDOM from 'react-dom'; import { PRESERVED_QUERYSTRING_PARAMS } from '../../core/constants'; import { clerkErrorPathRouterMissingPath } from '../../core/errors'; -import { buildVirtualRouterUrl } from '../../utils'; import { normalizeRoutingOptions } from '../../utils/normalizeRoutingOptions'; import { ComponentContext } from '../contexts'; import { HashRouter, PathRouter, VirtualRouter } from '../router'; @@ -18,18 +17,6 @@ type PortalProps; export class Portal extends React.PureComponent> { - private elRef = document.createElement('div'); - componentDidMount() { - if (this.props.componentName === 'OneTap') { - document.body.appendChild(this.elRef); - } - } - - componentWillUnmount() { - if (this.props.componentName === 'OneTap') { - document.body.removeChild(this.elRef); - } - } render() { const { props, component, componentName, node } = this.props; const normalizedProps = { ...props, ...normalizeRoutingOptions({ routing: props?.routing, path: props?.path }) }; @@ -42,13 +29,6 @@ export class Portal extends React.PureCom ); - if (componentName === 'OneTap') { - return ReactDOM.createPortal( - {el}, - this.elRef, - ); - } - if (normalizedProps?.routing === 'path') { if (!normalizedProps?.path) { clerkErrorPathRouterMissingPath(componentName); @@ -68,3 +48,36 @@ export class Portal extends React.PureCom return ReactDOM.createPortal({el}, node); } } + +type VirtualBodyRootPortalProps> = { + component: React.FunctionComponent | React.ComponentClass; + props?: PropsType; + startPath: string; +} & Pick; + +export class VirtualBodyRootPortal extends React.PureComponent< + VirtualBodyRootPortalProps +> { + private elRef = document.createElement('div'); + + componentDidMount() { + document.body.appendChild(this.elRef); + } + + componentWillUnmount() { + document.body.removeChild(this.elRef); + } + + render() { + const { props, startPath, component, componentName } = this.props; + + return ReactDOM.createPortal( + + + {React.createElement(component, props as PortalProps['props'])} + + , + this.elRef, + ); + } +} diff --git a/packages/clerk-js/src/utils/one-tap.ts b/packages/clerk-js/src/utils/one-tap.ts index 54ffd6cdf6..5284cdd294 100644 --- a/packages/clerk-js/src/utils/one-tap.ts +++ b/packages/clerk-js/src/utils/one-tap.ts @@ -15,9 +15,13 @@ interface InitializeProps { use_fedcm_for_prompt?: boolean; } +interface PromptMomentNotification { + getMomentType: () => 'display' | 'skipped' | 'dismissed'; +} + interface OneTapMethods { initialize: (params: InitializeProps) => void; - prompt: () => void; + prompt: (promptListener: (promptMomentNotification: PromptMomentNotification) => void) => void; cancel: () => void; } diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index f7621ecfff..3ed6416700 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -23,6 +23,7 @@ import { } from '../errors/messages'; import type { MountProps, + OpenProps, OrganizationProfileLinkProps, OrganizationProfilePageProps, UserProfileLinkProps, @@ -88,10 +89,22 @@ type OrganizationSwitcherPropsWithoutCustomPages = Without { + +const isMountProps = (props: any): props is MountProps => { + return 'mount' in props; +}; + +const isOpenProps = (props: any): props is OpenProps => { + return 'open' in props; +}; + +class Portal extends React.PureComponent { private portalRef = React.createRef(); - componentDidUpdate(_prevProps: Readonly) { + componentDidUpdate(_prevProps: Readonly) { + if (!isMountProps(_prevProps) || !isMountProps(this.props)) { + return; + } // Remove children and customPages from props before comparing // children might hold circular references which deepEqual can't handle // and the implementation of customPages relies on props getting new references @@ -106,13 +119,24 @@ class Portal extends React.PureComponent { componentDidMount() { if (this.portalRef.current) { - this.props.mount(this.portalRef.current, this.props.props); + if (isMountProps(this.props)) { + this.props.mount(this.portalRef.current, this.props.props); + } + + if (isOpenProps(this.props)) { + this.props.open(this.props.props); + } } } componentWillUnmount() { if (this.portalRef.current) { - this.props.unmount(this.portalRef.current); + if (isMountProps(this.props)) { + this.props.unmount(this.portalRef.current); + } + if (isOpenProps(this.props)) { + this.props.close(); + } } } @@ -120,7 +144,8 @@ class Portal extends React.PureComponent { return ( <>
- {this.props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))} + {isMountProps(this.props) && + this.props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))} ); } @@ -279,9 +304,8 @@ export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp) => { return ( ); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 4c26889497..a56728226e 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -95,7 +95,6 @@ type IsomorphicLoadedClerk = Without< | 'mountSignUp' | 'mountSignIn' | 'mountUserProfile' - | '__experimental_mountGoogleOneTap' | 'client' > & { // TODO: Align return type and parms @@ -131,7 +130,6 @@ type IsomorphicLoadedClerk = Without< mountOrganizationProfile: (node: HTMLDivElement, props: OrganizationProfileProps) => void; mountCreateOrganization: (node: HTMLDivElement, props: CreateOrganizationProps) => void; mountSignUp: (node: HTMLDivElement, props: SignUpProps) => void; - __experimental_mountGoogleOneTap: (node: HTMLDivElement, props: OneTapProps) => void; mountSignIn: (node: HTMLDivElement, props: SignInProps) => void; mountUserProfile: (node: HTMLDivElement, props: UserProfileProps) => void; client: ClientResource | undefined; @@ -142,6 +140,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private readonly options: IsomorphicClerkOptions; private readonly Clerk: ClerkProp; private clerkjs: BrowserClerk | HeadlessBrowserClerk | null = null; + private preopenOneTap?: null | OneTapProps = null; private preopenSignIn?: null | SignInProps = null; private preopenSignUp?: null | SignUpProps = null; private preopenUserProfile?: null | UserProfileProps = null; @@ -462,6 +461,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.openUserProfile(this.preopenUserProfile); } + if (this.preopenOneTap !== null) { + clerkjs.__experimental_openGoogleOneTap(this.preopenOneTap); + } + if (this.preopenOrganizationProfile !== null) { clerkjs.openOrganizationProfile(this.preopenOrganizationProfile); } @@ -594,6 +597,22 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + __experimental_openGoogleOneTap = (props?: OneTapProps): void => { + if (this.clerkjs && this.#loaded) { + this.clerkjs.__experimental_openGoogleOneTap(props); + } else { + this.preopenOneTap = props; + } + }; + + __experimental_closeGoogleOneTap = (): void => { + if (this.clerkjs && this.#loaded) { + this.clerkjs.__experimental_closeGoogleOneTap(); + } else { + this.preopenOneTap = null; + } + }; + openUserProfile = (props?: UserProfileProps): void => { if (this.clerkjs && this.#loaded) { this.clerkjs.openUserProfile(props); @@ -674,18 +693,6 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; - __experimental_mountGoogleOneTap = (node: HTMLDivElement, props: OneTapProps): void => { - if (this.clerkjs && this.#loaded) { - this.clerkjs.__experimental_mountGoogleOneTap(node, props); - } - }; - - __experimental_unmountGoogleOneTap = (node: HTMLDivElement): void => { - if (this.clerkjs && this.#loaded) { - this.clerkjs.__experimental_unmountGoogleOneTap(node); - } - }; - mountSignUp = (node: HTMLDivElement, props: SignUpProps): void => { if (this.clerkjs && this.#loaded) { this.clerkjs.mountSignUp(node, props); diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index df0ef9dec6..9c4ccd7d08 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -57,6 +57,12 @@ export interface MountProps { customPagesPortals?: any[]; } +export interface OpenProps { + open: (props: any) => void; + close: () => void; + props?: any; +} + export interface HeadlessBrowserClerk extends Clerk { load: (opts?: Without) => Promise; updateClient: (client: ClientResource) => void; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 343105eee9..4d8ad598c6 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -138,6 +138,20 @@ export interface Clerk { */ closeSignIn: () => void; + /** + * Opens the Google One Tap component. + * @experimental + * @param props Optional props that will be passed to the GoogleOneTap component. + */ + __experimental_openGoogleOneTap: (props?: OneTapProps) => void; + + /** + * Opens the Google One Tap component. + * If the component is not already open, results in a noop. + * @experimental + */ + __experimental_closeGoogleOneTap: () => void; + /** * Opens the Clerk SignUp component in a modal. * @param props Optional props that will be passed to the SignUp component. @@ -197,22 +211,6 @@ export interface Clerk { */ unmountSignIn: (targetNode: HTMLDivElement) => void; - /** - * Mounts a Google one tap flow component at the target element. - * @experimental - * @param targetNode Target node to mount the GoogleOneTap component. - * @param oneTapProps sign in configuration parameters. - */ - __experimental_mountGoogleOneTap: (targetNode: HTMLDivElement, oneTapProps?: OneTapProps) => void; - - /** - * Unmount a Google one tap flow component from the target element. - * If there is no component mounted at the target node, results in a noop. - * @experimental - * @param targetNode Target node to unmount the SignIn component from. - */ - __experimental_unmountGoogleOneTap: (targetNode: HTMLDivElement) => void; - /** * Mounts a sign up flow component at the target element. *