Skip to content

Commit

Permalink
feat(clerk-js,clerk-react): Replace mount with open for GoogleOneTap (#…
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
panteliselef authored May 16, 2024
1 parent cc7b76a commit 3d790d5
Show file tree
Hide file tree
Showing 12 changed files with 215 additions and 109 deletions.
7 changes: 7 additions & 0 deletions .changeset/warm-pumas-fetch.md
Original file line number Diff line number Diff line change
@@ -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`.
36 changes: 12 additions & 24 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 =>
Expand Down
33 changes: 29 additions & 4 deletions packages/clerk-js/src/ui/Components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ClerkOptions,
CreateOrganizationProps,
EnvironmentResource,
OneTapProps,
OrganizationProfileProps,
SignInProps,
SignUpProps,
Expand All @@ -32,6 +33,7 @@ import {
LazyComponentRenderer,
LazyImpersonationFabProvider,
LazyModalRenderer,
LazyOneTapRenderer,
LazyProviders,
} from './lazyModules/providers';
import type { AvailableComponentProps } from './types';
Expand All @@ -52,11 +54,15 @@ export type ComponentControls = {
node?: HTMLDivElement;
props?: unknown;
}) => void;
openModal: <T extends 'signIn' | 'signUp' | 'userProfile' | 'organizationProfile' | 'createOrganization'>(
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;
};
Expand All @@ -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;
Expand Down Expand Up @@ -153,6 +160,7 @@ const Components = (props: ComponentsProps) => {
const [state, setState] = React.useState<ComponentsState>({
appearance: props.options.appearance,
options: props.options,
googleOneTapModal: null,
signInModal: null,
signUpModal: null,
userProfileModal: null,
Expand All @@ -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();

Expand Down Expand Up @@ -219,6 +234,15 @@ const Components = (props: ComponentsProps) => {
props.onComponentsMounted();
}, []);

const mountedOneTapModal = (
<LazyOneTapRenderer
componentProps={googleOneTapModal}
globalAppearance={state.appearance}
componentAppearance={googleOneTapModal?.appearance}
startPath={buildVirtualRouterUrl({ base: '/one-tap', path: '' })}
/>
);

const mountedSignInModal = (
<LazyModalRenderer
globalAppearance={state.appearance}
Expand Down Expand Up @@ -323,6 +347,7 @@ const Components = (props: ComponentsProps) => {
);
})}

{googleOneTapModal && mountedOneTapModal}
{signInModal && mountedSignInModal}
{signUpModal && mountedSignUpModal}
{userProfileModal && mountedUserProfileModal}
Expand Down
50 changes: 29 additions & 21 deletions packages/clerk-js/src/ui/components/GoogleOneTap/one-tap-start.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,8 @@ export const useGoogleOneTapContext = () => {
options,
{
...ctx,
redirectUrl: window.location.href,
signInFallbackRedirectUrl: window.location.href,
signUpFallbackRedirectUrl: window.location.href,
},
queryParams,
);
Expand Down
25 changes: 25 additions & 0 deletions packages/clerk-js/src/ui/lazyModules/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -128,3 +129,27 @@ export const LazyImpersonationFabProvider = (
</Suspense>
);
};

type LazyOneTapRendererProps = React.PropsWithChildren<
{
componentProps: any;
startPath: string;
} & Omit<AppearanceProviderProps, 'appearanceKey'>
>;

export const LazyOneTapRenderer = (props: LazyOneTapRendererProps) => {
return (
<AppearanceProvider
globalAppearance={props.globalAppearance}
appearanceKey={'oneTap'}
appearance={props.componentAppearance}
>
<VirtualBodyRootPortal
startPath={props.startPath}
component={ClerkComponents['OneTap']}
props={props.componentProps}
componentName={'OneTap'}
/>
</AppearanceProvider>
);
};
53 changes: 33 additions & 20 deletions packages/clerk-js/src/ui/portal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,18 +17,6 @@ type PortalProps<CtxType extends AvailableComponentCtx, PropsType = Omit<CtxType
} & Pick<CtxType, 'componentName'>;

export class Portal<CtxType extends AvailableComponentCtx> extends React.PureComponent<PortalProps<CtxType>> {
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 }) };
Expand All @@ -42,13 +29,6 @@ export class Portal<CtxType extends AvailableComponentCtx> extends React.PureCom
</ComponentContext.Provider>
);

if (componentName === 'OneTap') {
return ReactDOM.createPortal(
<VirtualRouter startPath={buildVirtualRouterUrl({ base: '/one-tap', path: '' })}>{el}</VirtualRouter>,
this.elRef,
);
}

if (normalizedProps?.routing === 'path') {
if (!normalizedProps?.path) {
clerkErrorPathRouterMissingPath(componentName);
Expand All @@ -68,3 +48,36 @@ export class Portal<CtxType extends AvailableComponentCtx> extends React.PureCom
return ReactDOM.createPortal(<HashRouter preservedParams={PRESERVED_QUERYSTRING_PARAMS}>{el}</HashRouter>, node);
}
}

type VirtualBodyRootPortalProps<CtxType extends AvailableComponentCtx, PropsType = Omit<CtxType, 'componentName'>> = {
component: React.FunctionComponent<PropsType> | React.ComponentClass<PropsType, any>;
props?: PropsType;
startPath: string;
} & Pick<CtxType, 'componentName'>;

export class VirtualBodyRootPortal<CtxType extends AvailableComponentCtx> extends React.PureComponent<
VirtualBodyRootPortalProps<CtxType>
> {
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(
<VirtualRouter startPath={startPath}>
<ComponentContext.Provider value={{ componentName: componentName, ...props } as CtxType}>
<Suspense fallback={''}>{React.createElement(component, props as PortalProps<CtxType>['props'])}</Suspense>
</ComponentContext.Provider>
</VirtualRouter>,
this.elRef,
);
}
}
6 changes: 5 additions & 1 deletion packages/clerk-js/src/utils/one-tap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading

0 comments on commit 3d790d5

Please sign in to comment.