Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/google-one-tap-on-moment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@clerk/shared': minor
'@clerk/ui': minor
---

Add `onMoment` prop to `<GoogleOneTap>` for prompt lifecycle callbacks.

The new prop exposes Google's `PromptMomentNotification`, letting you track when the One Tap prompt is displayed, dismissed, or skipped.

```tsx
<GoogleOneTap
onMoment={(notification) => {
if (notification.isDisplayMoment()) {
// ...
}
}}
/>
```
28 changes: 28 additions & 0 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1606,6 +1606,26 @@ export type __internal_AttemptToEnableEnvironmentSettingResult = {
isEnabled: boolean;
};

export interface GoogleOneTapMomentNotification {
getMomentType: () => 'display' | 'skipped' | 'dismissed';
getDismissedReason: () => 'credential_returned' | 'cancel_called' | 'flow_restarted';
getNotDisplayedReason: () =>
| 'browser_not_supported'
| 'invalid_client'
| 'missing_client_id'
| 'opt_out_or_no_session'
| 'secure_http_required'
| 'suppressed_by_user'
| 'too_many_dismissals'
| 'unknown_reason';
getSkippedReason: () => 'auto_cancel' | 'user_cancel' | 'tap_outside' | 'issuing_failed';
isDisplayMoment: () => boolean;
isDismissedMoment: () => boolean;
isSkippedMoment: () => boolean;
isDisplayed: () => boolean;
isNotDisplayed: () => boolean;
}

type GoogleOneTapRedirectUrlProps = SignInForceRedirectUrl & SignUpForceRedirectUrl;

export type GoogleOneTapProps = GoogleOneTapRedirectUrlProps & {
Expand All @@ -1631,6 +1651,14 @@ export type GoogleOneTapProps = GoogleOneTapRedirectUrlProps & {
*/
fedCmSupport?: boolean;
appearance?: ClerkAppearanceTheme;
/**
* A callback that fires when the Google One Tap prompt moment changes.
* Receives a notification object with methods to query the moment type,
* display status, and reasons for dismissal or skipping.
*
* Useful for analytics (e.g., tracking display rate, dismissal rate, conversion funnel).
*/
onMoment?: (notification: GoogleOneTapMomentNotification) => void;
};

export type SignUpProps = RoutingOptions & {
Expand Down
166 changes: 166 additions & 0 deletions packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { clearFetchCache } from '@/ui/hooks/useFetch';
import { bindCreateFixtures } from '@/test/create-fixtures';
import { render, waitFor } from '@/test/utils';

import type { PromptMomentNotification } from '../../../utils/one-tap';

// Capture the prompt listener so we can invoke it in tests
let capturedPromptListener: ((notification: PromptMomentNotification) => void) | undefined;

const mockGoogle = {
accounts: {
id: {
initialize: vi.fn(),
prompt: vi.fn((listener: (notification: PromptMomentNotification) => void) => {
capturedPromptListener = listener;
}),
cancel: vi.fn(),
},
},
};

vi.mock('../../../utils/one-tap', async () => {
const actual = await vi.importActual('../../../utils/one-tap');
return {
...actual,
loadGIS: vi.fn(() => Promise.resolve(mockGoogle)),
};
});

const { createFixtures } = bindCreateFixtures('GoogleOneTap');

function createMockNotification(overrides: Partial<PromptMomentNotification> = {}): PromptMomentNotification {
return {
getMomentType: () => 'display',
getDismissedReason: () => 'credential_returned',
getNotDisplayedReason: () => 'browser_not_supported',
getSkippedReason: () => 'auto_cancel',
isDisplayMoment: () => true,
isDismissedMoment: () => false,
isSkippedMoment: () => false,
isDisplayed: () => true,
isNotDisplayed: () => false,
...overrides,
};
}

// Dynamically import the component after mock is set up
const { OneTapStart } = await import('../one-tap-start');

describe('OneTapStart', () => {
beforeEach(() => {
clearFetchCache();
capturedPromptListener = undefined;
mockGoogle.accounts.id.initialize.mockClear();
mockGoogle.accounts.id.prompt.mockClear();
mockGoogle.accounts.id.cancel.mockClear();
});

it('calls onMoment when the prompt fires a display moment', async () => {
const onMoment = vi.fn();
const { wrapper, props } = await createFixtures(f => {
f.withGoogleOneTap();
});

props.setProps({ onMoment });

render(<OneTapStart />, { wrapper });

await waitFor(() => {
expect(mockGoogle.accounts.id.prompt).toHaveBeenCalled();
});

// Simulate Google firing a display moment
const notification = createMockNotification({
getMomentType: () => 'display',
isDisplayMoment: () => true,
});
capturedPromptListener?.(notification);

expect(onMoment).toHaveBeenCalledOnce();
expect(onMoment).toHaveBeenCalledWith(notification);
});

it('calls onMoment and closes on skipped moment', async () => {
const onMoment = vi.fn();
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withGoogleOneTap();
});

props.setProps({ onMoment });

render(<OneTapStart />, { wrapper });

await waitFor(() => {
expect(mockGoogle.accounts.id.prompt).toHaveBeenCalled();
});

// Simulate Google firing a skipped moment
const notification = createMockNotification({
getMomentType: () => 'skipped',
isSkippedMoment: () => true,
isDisplayMoment: () => false,
});
capturedPromptListener?.(notification);

expect(onMoment).toHaveBeenCalledOnce();
expect(onMoment).toHaveBeenCalledWith(notification);
// Existing behavior: closeGoogleOneTap is called on skipped moments
expect(fixtures.clerk.closeGoogleOneTap).toHaveBeenCalled();
});

it('calls onMoment on dismissed moment without closing', async () => {
const onMoment = vi.fn();
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withGoogleOneTap();
});

props.setProps({ onMoment });

render(<OneTapStart />, { wrapper });

await waitFor(() => {
expect(mockGoogle.accounts.id.prompt).toHaveBeenCalled();
});

const notification = createMockNotification({
getMomentType: () => 'dismissed',
isDismissedMoment: () => true,
isDisplayMoment: () => false,
});
capturedPromptListener?.(notification);

expect(onMoment).toHaveBeenCalledOnce();
expect(onMoment).toHaveBeenCalledWith(notification);
// Should NOT close on dismissed (only on skipped)
expect(fixtures.clerk.closeGoogleOneTap).not.toHaveBeenCalled();
});

it('handles prompt without onMoment prop gracefully', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withGoogleOneTap();
});

// No onMoment prop set
props.setProps({});

render(<OneTapStart />, { wrapper });

await waitFor(() => {
expect(mockGoogle.accounts.id.prompt).toHaveBeenCalled();
});

// Simulate a skipped moment — should not throw even without onMoment
const notification = createMockNotification({
getMomentType: () => 'skipped',
isSkippedMoment: () => true,
isDisplayMoment: () => false,
});

expect(() => capturedPromptListener?.(notification)).not.toThrow();
// closeGoogleOneTap should still be called
expect(fixtures.clerk.closeGoogleOneTap).toHaveBeenCalled();
});
});
8 changes: 8 additions & 0 deletions packages/ui/src/components/GoogleOneTap/one-tap-start.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ function OneTapStartInternal(): JSX.Element | null {
const { navigate } = useRouter();

const ctx = useGoogleOneTapContext();
const onMomentRef = useRef(ctx.onMoment);
onMomentRef.current = ctx.onMoment;

async function oneTapCallback(response: GISCredentialResponse) {
isPromptedRef.current = false;
Expand Down Expand Up @@ -66,6 +68,12 @@ function OneTapStartInternal(): JSX.Element | null {
useEffect(() => {
if (initializedGoogle && !user?.id && !isPromptedRef.current) {
initializedGoogle.accounts.id.prompt(notification => {
try {
onMomentRef.current?.(notification);
} catch (e) {
console.error(e);
}

// 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
Expand Down
11 changes: 10 additions & 1 deletion packages/ui/src/test/fixture-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,16 @@ const createDisplayConfigFixtureHelpers = (environment: EnvironmentJSON) => {
dc.terms_url = opts.termsOfService || '';
dc.privacy_policy_url = opts.privacyPolicy || '';
};
return { withSupportEmail, withoutClerkBranding, withPreferredSignInStrategy, withTermsPrivacyPolicyUrls };
const withGoogleOneTap = (opts?: { clientId?: string }) => {
dc.google_one_tap_client_id = opts?.clientId || 'test-google-client-id';
};
return {
withSupportEmail,
withoutClerkBranding,
withPreferredSignInStrategy,
withTermsPrivacyPolicyUrls,
withGoogleOneTap,
};
};

const createOrganizationSettingsFixtureHelpers = (environment: EnvironmentJSON) => {
Expand Down
7 changes: 3 additions & 4 deletions packages/ui/src/utils/one-tap.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { clerkFailedToLoadThirdPartyScript } from '@clerk/shared/internal/clerk-js/errors';
import { loadScript } from '@clerk/shared/loadScript';
import type { GoogleOneTapMomentNotification } from '@clerk/shared/types';

interface GISCredentialResponse {
credential: string;
Expand All @@ -14,9 +15,7 @@ interface InitializeProps {
use_fedcm_for_prompt?: boolean;
}

interface PromptMomentNotification {
getMomentType: () => 'display' | 'skipped' | 'dismissed';
}
type PromptMomentNotification = GoogleOneTapMomentNotification;

interface OneTapMethods {
initialize: (params: InitializeProps) => void;
Expand Down Expand Up @@ -54,4 +53,4 @@ async function loadGIS() {
}

export { loadGIS };
export type { GISCredentialResponse };
export type { GISCredentialResponse, PromptMomentNotification };
Loading