From 675e29e659c238123f857321ad96b1d07de0926e Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Tue, 17 Mar 2026 12:46:13 -0400 Subject: [PATCH] feat(shared,ui): Add onMoment prop to GoogleOneTap --- .changeset/google-one-tap-on-moment.md | 18 ++ packages/shared/src/types/clerk.ts | 28 +++ .../__tests__/OneTapStart.test.tsx | 166 ++++++++++++++++++ .../components/GoogleOneTap/one-tap-start.tsx | 8 + packages/ui/src/test/fixture-helpers.ts | 11 +- packages/ui/src/utils/one-tap.ts | 7 +- 6 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 .changeset/google-one-tap-on-moment.md create mode 100644 packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx diff --git a/.changeset/google-one-tap-on-moment.md b/.changeset/google-one-tap-on-moment.md new file mode 100644 index 00000000000..c6f9b2b5281 --- /dev/null +++ b/.changeset/google-one-tap-on-moment.md @@ -0,0 +1,18 @@ +--- +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Add `onMoment` prop to `` 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 + { + if (notification.isDisplayMoment()) { + // ... + } + }} +/> +``` diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 670a1a21ba0..e1b313d0cf4 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -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 & { @@ -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 & { diff --git a/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx b/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx new file mode 100644 index 00000000000..b7af2b859ac --- /dev/null +++ b/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx @@ -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 { + 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(, { 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(, { 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(, { 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(, { 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(); + }); +}); diff --git a/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx b/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx index 6f058d40192..160dffeac63 100644 --- a/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx +++ b/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx @@ -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; @@ -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 diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts index 460117de14a..91c914f8169 100644 --- a/packages/ui/src/test/fixture-helpers.ts +++ b/packages/ui/src/test/fixture-helpers.ts @@ -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) => { diff --git a/packages/ui/src/utils/one-tap.ts b/packages/ui/src/utils/one-tap.ts index e3946eda692..d7fa606e75c 100644 --- a/packages/ui/src/utils/one-tap.ts +++ b/packages/ui/src/utils/one-tap.ts @@ -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; @@ -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; @@ -54,4 +53,4 @@ async function loadGIS() { } export { loadGIS }; -export type { GISCredentialResponse }; +export type { GISCredentialResponse, PromptMomentNotification };