From 8312bb29d27c6913dbb6cd833af4b825fba4b124 Mon Sep 17 00:00:00 2001 From: Rounak Agarwal Date: Thu, 22 Oct 2026 12:27:32 +0000 Subject: [PATCH 1/2] fix(auth): render the Google sign-in button at the form's width renderButton was given width: '100%', but Google Identity Services only accepts a pixel width (200-400), so it ignored the value and shrank the button to fit its text. Measure the container, clamp to Google's range, and centre the button. Applies to both the sign-in and sign-up forms. --- .../Authentication/__tests__/forms.test.tsx | 57 +++++++++++++++++++ frontend/src/Pages/Authentication/forms.tsx | 13 +++-- frontend/src/types/google.d.ts | 3 +- 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 frontend/src/Pages/Authentication/__tests__/forms.test.tsx diff --git a/frontend/src/Pages/Authentication/__tests__/forms.test.tsx b/frontend/src/Pages/Authentication/__tests__/forms.test.tsx new file mode 100644 index 00000000..ca6ed0dc --- /dev/null +++ b/frontend/src/Pages/Authentication/__tests__/forms.test.tsx @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { LoginForm, SignUpForm } from '../forms'; +import { AuthContext } from '../../../context/authContext'; + +const renderButton = vi.fn(); + +function withAuth(ui: React.ReactElement) { + const auth = { + login: vi.fn(), + signup: vi.fn(), + googleLogin: vi.fn(), + clearError: vi.fn(), + error: null, + loading: false, + }; + return render({ui}); +} + +// jsdom has no layout, so give the button container a measurable width. +function stubContainerWidth(width: number) { + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(width); +} + +describe('Google sign-in button', () => { + beforeEach(() => { + renderButton.mockReset(); + window.google = { + accounts: { id: { initialize: vi.fn(), renderButton, prompt: vi.fn(), cancel: vi.fn() } }, + }; + }); + afterEach(() => { + delete window.google; + }); + + it('passes a pixel width matching the form (Google ignores percentages)', () => { + stubContainerWidth(320); + withAuth(); + const [element, options] = renderButton.mock.calls[0]; + expect(element).toHaveAttribute('id', 'googleSignInButton'); + expect(options.width).toBe(320); + }); + + it('caps the width at Google\'s 400px maximum', () => { + stubContainerWidth(478); + withAuth(); + expect(renderButton.mock.calls[0][1].width).toBe(400); + }); + + it('never goes below Google\'s 200px minimum', () => { + stubContainerWidth(0); + withAuth(); + const [element, options] = renderButton.mock.calls[0]; + expect(element).toHaveAttribute('id', 'googleSignUpButton'); + expect(options.width).toBe(200); + }); +}); diff --git a/frontend/src/Pages/Authentication/forms.tsx b/frontend/src/Pages/Authentication/forms.tsx index d328386c..6fa8cef7 100644 --- a/frontend/src/Pages/Authentication/forms.tsx +++ b/frontend/src/Pages/Authentication/forms.tsx @@ -6,6 +6,11 @@ import { useContext, useState, useEffect } from 'react'; import { AuthContext } from '../../context/authContext'; import { useCallback } from "react"; +// Google's renderButton only accepts a pixel width between 200 and 400; +// a percentage is ignored and the button shrinks to fit its text. +const googleButtonWidth = (element: HTMLElement) => + Math.round(Math.min(400, Math.max(200, element.clientWidth))); + interface LoginFormProps { startForgotPassword: () => void; infoMessage?: string; @@ -80,7 +85,7 @@ export const LoginForm: React.FC = ({ startForgotPassword, infoM theme: 'outline', size: 'large', text: 'signin_with', - width: '100%', + width: googleButtonWidth(buttonElement), }); } @@ -139,7 +144,7 @@ export const LoginForm: React.FC = ({ startForgotPassword, infoM -
+
); }; @@ -212,7 +217,7 @@ export const SignUpForm: React.FC = ({ startOtpVerification }) theme: 'outline', size: 'large', text: 'signup_with', - width: '100%', + width: googleButtonWidth(buttonElement), }); } @@ -268,7 +273,7 @@ export const SignUpForm: React.FC = ({ startOtpVerification }) -
+
); }; diff --git a/frontend/src/types/google.d.ts b/frontend/src/types/google.d.ts index cb00bc61..035ad4bd 100644 --- a/frontend/src/types/google.d.ts +++ b/frontend/src/types/google.d.ts @@ -13,7 +13,8 @@ interface GoogleId { theme: 'outline' | 'filled_blue' | 'filled_black'; size: 'large' | 'medium' | 'small'; text?: 'signin_with' | 'signup_with' | 'continue_with'; - width?: string; + /** Button width in pixels (Google accepts 200–400); percentages are ignored. */ + width?: number; shape?: 'rectangular' | 'pill'; logo_alignment?: 'left' | 'center'; } From 88f87f89a9b38fe02f74263653c6a1ef01960cd3 Mon Sep 17 00:00:00 2001 From: Rounak Agarwal Date: Thu, 29 Oct 2026 04:04:55 +0000 Subject: [PATCH 2/2] feat(auth): redraw the Google button on resize and wait for the Google script Move the Google button setup into a shared useGoogleButton hook used by both the login and sign-up forms: - Redraw the button at the container's new width when it changes (ResizeObserver, debounced 100 ms), so it keeps matching the form after a window resize or phone rotation. - If the Google script hasn't loaded yet when the form mounts, poll for it every 100 ms and give up with a console warning after 10 s. - Initialize Google once per mount; skip redraws when the width is unchanged. The resize and script-wait approach is adapted from #774 by @aryanpatil-07. --- .../Authentication/__tests__/forms.test.tsx | 126 ++++++++++++++++-- frontend/src/Pages/Authentication/forms.tsx | 62 +-------- frontend/src/hooks/useGoogleButton.ts | 96 +++++++++++++ 3 files changed, 214 insertions(+), 70 deletions(-) create mode 100644 frontend/src/hooks/useGoogleButton.ts diff --git a/frontend/src/Pages/Authentication/__tests__/forms.test.tsx b/frontend/src/Pages/Authentication/__tests__/forms.test.tsx index ca6ed0dc..0219669c 100644 --- a/frontend/src/Pages/Authentication/__tests__/forms.test.tsx +++ b/frontend/src/Pages/Authentication/__tests__/forms.test.tsx @@ -1,15 +1,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { render } from '@testing-library/react'; +import { act, render } from '@testing-library/react'; import { LoginForm, SignUpForm } from '../forms'; import { AuthContext } from '../../../context/authContext'; +import { + GOOGLE_BUTTON_RESIZE_DEBOUNCE_MS, + GOOGLE_SCRIPT_POLL_MS, + GOOGLE_SCRIPT_TIMEOUT_MS, +} from '../../../hooks/useGoogleButton'; const renderButton = vi.fn(); +const initialize = vi.fn(); +const cancel = vi.fn(); +const googleLogin = vi.fn(); function withAuth(ui: React.ReactElement) { const auth = { login: vi.fn(), signup: vi.fn(), - googleLogin: vi.fn(), + googleLogin, clearError: vi.fn(), error: null, loading: false, @@ -17,41 +25,133 @@ function withAuth(ui: React.ReactElement) { return render({ui}); } +const renderLogin = () => withAuth(); + +function installGoogle() { + window.google = { accounts: { id: { initialize, renderButton, prompt: vi.fn(), cancel } } }; +} + // jsdom has no layout, so give the button container a measurable width. +let widthSpy: ReturnType; function stubContainerWidth(width: number) { - vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(width); + widthSpy = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(width); } +// ResizeObserver that tests can trigger by hand. +let resizeCallbacks: Array<() => void> = []; +const disconnect = vi.fn(); +class FakeResizeObserver { + constructor(cb: () => void) { + resizeCallbacks.push(cb); + } + observe() {} + unobserve() {} + disconnect = disconnect; +} +const resize = (width: number) => { + widthSpy.mockReturnValue(width); + act(() => resizeCallbacks.forEach((cb) => cb())); +}; + describe('Google sign-in button', () => { beforeEach(() => { - renderButton.mockReset(); - window.google = { - accounts: { id: { initialize: vi.fn(), renderButton, prompt: vi.fn(), cancel: vi.fn() } }, - }; + vi.useFakeTimers(); + [renderButton, initialize, cancel, googleLogin, disconnect].forEach((m) => m.mockReset()); + resizeCallbacks = []; + vi.stubGlobal('ResizeObserver', FakeResizeObserver); + installGoogle(); }); afterEach(() => { delete window.google; + vi.useRealTimers(); + vi.unstubAllGlobals(); }); it('passes a pixel width matching the form (Google ignores percentages)', () => { stubContainerWidth(320); - withAuth(); + renderLogin(); const [element, options] = renderButton.mock.calls[0]; expect(element).toHaveAttribute('id', 'googleSignInButton'); - expect(options.width).toBe(320); + expect(options).toMatchObject({ width: 320, text: 'signin_with', theme: 'outline', size: 'large' }); }); - it('caps the width at Google\'s 400px maximum', () => { + it("caps the width at Google's 400px maximum", () => { stubContainerWidth(478); - withAuth(); + renderLogin(); expect(renderButton.mock.calls[0][1].width).toBe(400); }); - it('never goes below Google\'s 200px minimum', () => { + it("never goes below Google's 200px minimum", () => { stubContainerWidth(0); withAuth(); const [element, options] = renderButton.mock.calls[0]; expect(element).toHaveAttribute('id', 'googleSignUpButton'); - expect(options.width).toBe(200); + expect(options).toMatchObject({ width: 200, text: 'signup_with' }); + }); + + it('redraws at the new width after a resize settles', () => { + stubContainerWidth(320); + renderLogin(); + expect(renderButton).toHaveBeenCalledTimes(1); + + resize(260); + expect(renderButton).toHaveBeenCalledTimes(1); // still debouncing + act(() => vi.advanceTimersByTime(GOOGLE_BUTTON_RESIZE_DEBOUNCE_MS)); + expect(renderButton).toHaveBeenCalledTimes(2); + expect(renderButton.mock.calls[1][1].width).toBe(260); + }); + + it('does not redraw when the width is unchanged, and initializes Google once', () => { + stubContainerWidth(320); + renderLogin(); + resize(320); + act(() => vi.advanceTimersByTime(GOOGLE_BUTTON_RESIZE_DEBOUNCE_MS)); + resize(300); + act(() => vi.advanceTimersByTime(GOOGLE_BUTTON_RESIZE_DEBOUNCE_MS)); + expect(renderButton).toHaveBeenCalledTimes(2); + expect(initialize).toHaveBeenCalledTimes(1); + }); + + it('waits for the Google script when it loads after the form', () => { + delete window.google; + stubContainerWidth(320); + renderLogin(); + act(() => vi.advanceTimersByTime(GOOGLE_SCRIPT_POLL_MS * 3)); + expect(renderButton).not.toHaveBeenCalled(); + + installGoogle(); + act(() => vi.advanceTimersByTime(GOOGLE_SCRIPT_POLL_MS)); + expect(initialize).toHaveBeenCalledTimes(1); + expect(renderButton.mock.calls[0][1].width).toBe(320); + }); + + it('stops waiting if the Google script never loads', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete window.google; + stubContainerWidth(320); + renderLogin(); + act(() => vi.advanceTimersByTime(GOOGLE_SCRIPT_TIMEOUT_MS + GOOGLE_SCRIPT_POLL_MS)); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Google sign-in script did not load')); + + // Loading it later no longer renders anything: polling has stopped. + installGoogle(); + act(() => vi.advanceTimersByTime(GOOGLE_SCRIPT_POLL_MS * 5)); + expect(renderButton).not.toHaveBeenCalled(); + }); + + it('sends the Google credential to googleLogin', () => { + stubContainerWidth(320); + renderLogin(); + const { callback } = initialize.mock.calls[0][0]; + callback({ credential: 'id-token', select_by: 'btn' }); + expect(googleLogin).toHaveBeenCalledWith('id-token'); + }); + + it('cleans up on unmount', () => { + stubContainerWidth(320); + const { unmount } = renderLogin(); + unmount(); + expect(disconnect).toHaveBeenCalled(); + expect(cancel).toHaveBeenCalled(); }); }); diff --git a/frontend/src/Pages/Authentication/forms.tsx b/frontend/src/Pages/Authentication/forms.tsx index 6fa8cef7..a847808f 100644 --- a/frontend/src/Pages/Authentication/forms.tsx +++ b/frontend/src/Pages/Authentication/forms.tsx @@ -5,11 +5,7 @@ import { Input } from '@/components/ui/input'; import { useContext, useState, useEffect } from 'react'; import { AuthContext } from '../../context/authContext'; import { useCallback } from "react"; - -// Google's renderButton only accepts a pixel width between 200 and 400; -// a percentage is ignored and the button shrinks to fit its text. -const googleButtonWidth = (element: HTMLElement) => - Math.round(Math.min(400, Math.max(200, element.clientWidth))); +import { useGoogleButton } from '../../hooks/useGoogleButton'; interface LoginFormProps { startForgotPassword: () => void; @@ -68,31 +64,7 @@ export const LoginForm: React.FC = ({ startForgotPassword, infoM }, [googleLogin] ); - useEffect(() => { - const google = window.google; - if (!google?.accounts) { - return; - } - - google.accounts.id.initialize({ - client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID, - callback: handleGoogleLogin, - }); - - const buttonElement = document.getElementById('googleSignInButton'); - if (buttonElement) { - google.accounts.id.renderButton(buttonElement, { - theme: 'outline', - size: 'large', - text: 'signin_with', - width: googleButtonWidth(buttonElement), - }); - } - - return () => { - google.accounts.id.cancel(); - }; - }, [handleGoogleLogin]); + const googleButtonRef = useGoogleButton('signin_with', handleGoogleLogin); return (
@@ -144,7 +116,7 @@ export const LoginForm: React.FC = ({ startForgotPassword, infoM -
+
); }; @@ -200,31 +172,7 @@ export const SignUpForm: React.FC = ({ startOtpVerification }) ); - useEffect(() => { - const google = window.google; - if (!google?.accounts) { - return; - } - - google.accounts.id.initialize({ - client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID, - callback: handleGoogleLogin, - }); - - const buttonElement = document.getElementById('googleSignUpButton'); - if (buttonElement) { - google.accounts.id.renderButton(buttonElement, { - theme: 'outline', - size: 'large', - text: 'signup_with', - width: googleButtonWidth(buttonElement), - }); - } - - return () => { - google.accounts.id.cancel(); - }; - }, [handleGoogleLogin]); + const googleButtonRef = useGoogleButton('signup_with', handleGoogleLogin); return (
@@ -273,7 +221,7 @@ export const SignUpForm: React.FC = ({ startOtpVerification }) -
+
); }; diff --git a/frontend/src/hooks/useGoogleButton.ts b/frontend/src/hooks/useGoogleButton.ts new file mode 100644 index 00000000..432a9485 --- /dev/null +++ b/frontend/src/hooks/useGoogleButton.ts @@ -0,0 +1,96 @@ +import { useEffect, useRef } from 'react'; + +type GoogleButtonText = 'signin_with' | 'signup_with' | 'continue_with'; +type GoogleCredentialResponse = { credential: string; select_by: string }; + +// Google's renderButton only accepts a pixel width between 200 and 400; a +// percentage is ignored and the button shrinks to fit its text. +export const GOOGLE_BUTTON_MIN_WIDTH = 200; +export const GOOGLE_BUTTON_MAX_WIDTH = 400; +export const googleButtonWidth = (containerWidth: number) => + Math.round(Math.min(GOOGLE_BUTTON_MAX_WIDTH, Math.max(GOOGLE_BUTTON_MIN_WIDTH, containerWidth))); + +// The Google Identity Services script loads async, so it may arrive after +// the form mounts. Wait for it, but stop if it never comes (blocked, offline). +export const GOOGLE_SCRIPT_POLL_MS = 100; +export const GOOGLE_SCRIPT_TIMEOUT_MS = 10_000; +// Wait for resizing to settle before redrawing the button. +export const GOOGLE_BUTTON_RESIZE_DEBOUNCE_MS = 100; + +/** + * Renders Google's sign-in button into the returned ref, as wide as its + * container, and redraws it when the container's width changes (window + * resize, phone rotation). Google is initialized once per mount. + * + * Responsive re-rendering adapted from the approach in #774 by @aryanpatil-07. + */ +export function useGoogleButton( + text: GoogleButtonText, + onCredential: (response: GoogleCredentialResponse) => void +) { + const ref = useRef(null); + // Keep the latest callback without re-initializing Google when it changes. + const callbackRef = useRef(onCredential); + useEffect(() => { + callbackRef.current = onCredential; + }, [onCredential]); + + useEffect(() => { + const container = ref.current; + if (!container) return; + + let pollTimer: ReturnType | undefined; + let resizeTimer: ReturnType | undefined; + let observer: ResizeObserver | undefined; + let drawnWidth = 0; + + const draw = () => { + const google = window.google; + if (!google?.accounts) return; + const width = googleButtonWidth(container.clientWidth); + if (width === drawnWidth) return; + drawnWidth = width; + container.innerHTML = ''; + google.accounts.id.renderButton(container, { theme: 'outline', size: 'large', text, width }); + }; + + const start = () => { + window.google?.accounts.id.initialize({ + client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID, + callback: (response) => callbackRef.current(response), + }); + draw(); + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(() => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(draw, GOOGLE_BUTTON_RESIZE_DEBOUNCE_MS); + }); + observer.observe(container); + } + }; + + if (window.google?.accounts) { + start(); + } else { + const startedAt = Date.now(); + pollTimer = setInterval(() => { + if (window.google?.accounts) { + clearInterval(pollTimer); + start(); + } else if (Date.now() - startedAt >= GOOGLE_SCRIPT_TIMEOUT_MS) { + clearInterval(pollTimer); + console.warn('Google sign-in script did not load; the Google button is unavailable.'); + } + }, GOOGLE_SCRIPT_POLL_MS); + } + + return () => { + clearInterval(pollTimer); + clearTimeout(resizeTimer); + observer?.disconnect(); + window.google?.accounts?.id?.cancel(); + }; + }, [text]); + + return ref; +}