From 78a8e8141f602a20fcc636f54b5d43ffe47c8863 Mon Sep 17 00:00:00 2001 From: Alexander Kolberg Date: Mon, 13 Jul 2026 12:31:42 +0300 Subject: [PATCH 1/4] fix(connect): backport reliable Google WaaS sign-in to v5 --- .changeset/bright-mice-connect.md | 5 + .../src/components/Connect/Connect.tsx | 152 +++++++----- .../ConnectButton/ConnectButton.tsx | 142 +++++++---- .../GoogleSignInButton/GoogleSignInButton.tsx | 221 ++++++++++++++++++ .../src/components/SocialLink/SocialLink.tsx | 7 +- packages/connect/src/styles.ts | 15 ++ 6 files changed, 442 insertions(+), 100 deletions(-) create mode 100644 .changeset/bright-mice-connect.md create mode 100644 packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.tsx diff --git a/.changeset/bright-mice-connect.md b/.changeset/bright-mice-connect.md new file mode 100644 index 000000000..a80448ff8 --- /dev/null +++ b/.changeset/bright-mice-connect.md @@ -0,0 +1,5 @@ +--- +'@0xsequence/connect': patch +--- + +Fix Google WaaS v2 sign-in clicks failing in Chromium by replacing the hidden, transformed iframe with a directly interactive Google button. diff --git a/packages/connect/src/components/Connect/Connect.tsx b/packages/connect/src/components/Connect/Connect.tsx index b31638974..b1d849e34 100644 --- a/packages/connect/src/components/Connect/Connect.tsx +++ b/packages/connect/src/components/Connect/Connect.tsx @@ -382,9 +382,53 @@ export const Connect = (props: ConnectProps) => { const showMoreSocialOptions = socialAuthConnectors.length > MAX_ITEM_PER_ROW const showMoreWalletOptions = walletConnectors.length > MAX_ITEM_PER_ROW + const googleSocialConnector = socialAuthConnectors.find(connector => connector._wallet?.id === 'google-waas') const socialConnectorsPerRow = showMoreSocialOptions && !descriptiveSocials ? MAX_ITEM_PER_ROW - 1 : socialAuthConnectors.length const walletConnectorsPerRow = showMoreWalletOptions ? MAX_ITEM_PER_ROW - 1 : walletConnectors.length + const renderSocialConnectorButton = ( + connector: ExtendedConnector, + options?: { isDescriptive?: boolean; googleButtonTheme?: 'filled_blue' | 'outline' } + ) => { + const isDescriptive = options?.isDescriptive ?? descriptiveSocials + + switch (connector._wallet.id) { + case 'guest-waas': + return ( + + ) + case 'google-waas': + return ( + + ) + case 'apple-waas': + return + case 'epic-waas': + return + case 'X-waas': + return + default: + return ( + + ) + } + } + const WalletConnectorsSection = () => { return (
@@ -398,7 +442,10 @@ export const Connect = (props: ConnectProps) => { if (showExtendedList) { const SEARCHABLE_TRESHOLD = 8 - const connectors = showExtendedList === 'social' ? socialAuthConnectors : walletConnectors + const connectors = + showExtendedList === 'social' + ? socialAuthConnectors.filter(connector => connector._wallet?.id !== 'google-waas') + : walletConnectors const searchable = connectors.length > SEARCHABLE_TRESHOLD return ( @@ -522,62 +569,63 @@ export const Connect = (props: ConnectProps) => { {showWalletAuthOptionsFirst && !hideExternalConnectOptions && walletConnectors.length > 0 && ( )} -
+
<> - {!hideSocialConnectOptions && showSocialConnectorSection && ( -
- {socialAuthConnectors.slice(0, socialConnectorsPerRow).map(connector => { + {!hideSocialConnectOptions && + showSocialConnectorSection && + (() => { + const visibleSocialConnectors = socialAuthConnectors.slice(0, socialConnectorsPerRow) + const otherConnectors = visibleSocialConnectors.filter( + connector => connector._wallet?.id !== 'google-waas' + ) + const renderConnectorGroup = (connectors: ExtendedConnector[], includeShowMore = false) => ( +
+ {connectors.map(connector => ( +
+ {renderSocialConnectorButton(connector)} +
+ ))} + {includeShowMore && ( +
+ setShowExtendedList('social')} /> +
+ )} +
+ ) + + if (!googleSocialConnector) { + return renderConnectorGroup(visibleSocialConnectors, showMoreSocialOptions) + } + + if (descriptiveSocials) { return ( -
- {connector._wallet.id === 'guest-waas' ? ( - - ) : connector._wallet.id === 'google-waas' ? ( - - ) : connector._wallet.id === 'apple-waas' ? ( - - ) : connector._wallet.id === 'epic-waas' ? ( - - ) : connector._wallet.id === 'X-waas' ? ( - - ) : ( - - )} +
+ +
+ {renderSocialConnectorButton(googleSocialConnector, { + isDescriptive: true, + googleButtonTheme: 'filled_blue' + })} +
+ + {renderConnectorGroup(otherConnectors, showMoreSocialOptions)}
) - })} - {showMoreSocialOptions && ( -
- setShowExtendedList('social')} /> + } + + return ( +
+ {renderConnectorGroup(otherConnectors, showMoreSocialOptions)} + + {renderSocialConnectorButton(googleSocialConnector, { + isDescriptive: true, + googleButtonTheme: 'outline' + })}
- )} -
- )} + ) + })()} {!hideSocialConnectOptions && showSocialConnectorSection && showEmailInputSection && (
diff --git a/packages/connect/src/components/ConnectButton/ConnectButton.tsx b/packages/connect/src/components/ConnectButton/ConnectButton.tsx index fb1a15f28..917e7cea2 100644 --- a/packages/connect/src/components/ConnectButton/ConnectButton.tsx +++ b/packages/connect/src/components/ConnectButton/ConnectButton.tsx @@ -1,15 +1,18 @@ import { Card, ContextMenuIcon, Text, Tooltip, useTheme } from '@0xsequence/design-system' -import { GoogleLogin } from '@react-oauth/google' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { appleAuthHelpers } from 'react-apple-signin-auth' import { getXIdToken } from '../../connectors/X/XAuth.js' import { LocalStorageKey } from '../../constants/localStorage.js' import { useStorage, useStorageItem } from '../../hooks/useStorage.js' import type { ExtendedConnector, WalletProperties } from '../../types.js' +import { GoogleSignInButton, type GoogleButtonTheme } from '../GoogleSignInButton/GoogleSignInButton.js' const BUTTON_HEIGHT = '52px' const BUTTON_HEIGHT_DESCRIPTIVE = '44px' +const GOOGLE_BUTTON_HEIGHT_DESCRIPTIVE = '40px' +// Standard Google buttons have an intrinsic localized text width; narrow connector cells use the official icon variant. +const GOOGLE_STANDARD_BUTTON_MIN_WIDTH = 240 const iconSizeClasses = 'w-8 h-8' const iconDescriptiveSizeClasses = 'w-6 h-6' @@ -26,6 +29,12 @@ interface ConnectButtonProps { disableTooltip?: boolean } +type GoogleWaasConnector = ExtendedConnector & { + params?: { + googleClientId?: string + } +} + export const ConnectButton = (props: ConnectButtonProps) => { const { connector, label, disableTooltip, onConnect } = props const { theme } = useTheme() @@ -105,71 +114,112 @@ export const GuestWaasConnectButton = (props: ConnectButtonProps & { setIsLoadin ) } -export const GoogleWaasConnectButton = (props: ConnectButtonProps) => { - const { connector, onConnect, isDescriptive = false } = props +export const GoogleWaasConnectButton = ( + props: ConnectButtonProps & { + buttonTheme?: 'filled_blue' | 'outline' + } +) => { + const { connector, onConnect, isDescriptive = false, buttonTheme = 'outline' } = props const storage = useStorage() + const containerRef = useRef(null) + const isMountedRef = useRef(true) + const [buttonWidth, setButtonWidth] = useState(0) const { theme } = useTheme() - const walletProps = connector._wallet + const googleClientId = (connector as GoogleWaasConnector).params?.googleClientId ?? '' - const Logo = getLogo(theme, walletProps) + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + } + }, []) - const WaasLoginContent = () => { - if (isDescriptive) { - return ( -
- - - Continue with Google - -
- ) + useEffect(() => { + let hasMeasured = false + let resizeObserver: ResizeObserver | undefined + + const measureInitialWidth = () => { + if (hasMeasured) { + return + } + + const availableWidth = containerRef.current?.clientWidth ?? 0 + if (availableWidth === 0) { + return + } + + hasMeasured = true + const nextButtonWidth = Math.min(400, Math.floor(availableWidth)) + setButtonWidth(nextButtonWidth) + resizeObserver?.disconnect() + window.removeEventListener('resize', measureInitialWidth) } - return ( -
- -
- ) - } + measureInitialWidth() + + if (!hasMeasured) { + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measureInitialWidth) + } else { + resizeObserver = new ResizeObserver(measureInitialWidth) + if (containerRef.current) { + resizeObserver.observe(containerRef.current) + } + } + } + + return () => { + resizeObserver?.disconnect() + window.removeEventListener('resize', measureInitialWidth) + } + }, []) - const buttonHeight = isDescriptive ? BUTTON_HEIGHT_DESCRIPTIVE : BUTTON_HEIGHT + const useIconButton = buttonWidth < GOOGLE_STANDARD_BUTTON_MIN_WIDTH + const buttonHeight = isDescriptive ? GOOGLE_BUTTON_HEIGHT_DESCRIPTIVE : BUTTON_HEIGHT + // GIS supports outline_dark, but @react-oauth/google's theme type has not caught up with the current API. + const googleButtonTheme: GoogleButtonTheme = + buttonTheme === 'filled_blue' ? 'filled_blue' : theme === 'dark' ? 'outline_dark' : 'outline' return ( - - -
- +
+ {buttonWidth > 0 && ( + { + // GIS may finish after the modal has unmounted; ignore stale callbacks after dismissal. + if (!isMountedRef.current) { + return + } + if (credentialResponse.credential) { storage?.setItem(LocalStorageKey.WaasGoogleIdToken, credentialResponse.credential) onConnect(connector) } }} onError={() => { + if (!isMountedRef.current) { + return + } + console.log('Login Failed') }} /> -
- - - - + )} +
+
) } diff --git a/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.tsx b/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.tsx new file mode 100644 index 000000000..3458f3995 --- /dev/null +++ b/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.tsx @@ -0,0 +1,221 @@ +import { Skeleton } from '@0xsequence/design-system' +import type { CredentialResponse, GsiButtonConfiguration, IdConfiguration } from '@react-oauth/google' +import { useEffect, useRef, useState } from 'react' + +export type GoogleButtonTheme = 'outline' | 'outline_dark' | 'filled_blue' | 'filled_black' + +interface RoutedCredentialResponse extends CredentialResponse { + state?: string +} + +interface GoogleButtonConfiguration extends Omit { + state?: string + theme?: GoogleButtonTheme +} + +interface GoogleIdentityApi { + initialize: (config: IdConfiguration) => void + renderButton: (parent: HTMLElement, options: GoogleButtonConfiguration) => void +} + +interface GoogleSignInButtonProps extends Omit { + clientId: string + theme?: GoogleButtonTheme + onSuccess: (credentialResponse: CredentialResponse) => void + onError?: () => void +} + +const buttonHeights = { + large: 40, + medium: 32, + small: 20 +} as const +const MIN_LOADING_DURATION_MS = 600 + +type CredentialHandler = (credentialResponse: CredentialResponse) => void + +let initializedGoogleIdentity: GoogleIdentityApi | undefined +let initializedGoogleClientId: string | undefined +let nextGoogleButtonId = 0 +const credentialHandlers = new Map() + +const getGoogleIdentity = (): GoogleIdentityApi | undefined => (window as any).google?.accounts?.id + +const initializeGoogleIdentity = (googleIdentity: GoogleIdentityApi, clientId: string) => { + if (initializedGoogleIdentity === googleIdentity) { + if (initializedGoogleClientId !== clientId) { + console.error('Google Identity Services cannot be initialized with multiple client IDs on the same page.') + return false + } + return true + } + + googleIdentity.initialize({ + client_id: clientId, + callback: credentialResponse => { + const buttonState = (credentialResponse as RoutedCredentialResponse).state + const credentialHandler = buttonState ? credentialHandlers.get(buttonState) : undefined + + if (!credentialHandler) { + console.error('Unable to match the Google credential response to the button that initiated it.') + return + } + + credentialHandler(credentialResponse) + } + }) + initializedGoogleIdentity = googleIdentity + initializedGoogleClientId = clientId + return true +} + +export const GoogleSignInButton = ({ + clientId, + onSuccess, + onError, + type = 'standard', + theme = 'outline', + size = 'large', + text, + shape, + logo_alignment, + width, + locale, + click_listener +}: GoogleSignInButtonProps) => { + const containerRef = useRef(null) + const onSuccessRef = useRef(onSuccess) + const onErrorRef = useRef(onError) + const buttonStateRef = useRef(null) + if (buttonStateRef.current === null) { + buttonStateRef.current = `sequence-google-${++nextGoogleButtonId}` + } + const buttonState = buttonStateRef.current + const renderSignature = [clientId, type, theme, size, text, shape, logo_alignment, width, locale].join('|') + const [readySignature, setReadySignature] = useState() + const isReady = readySignature === renderSignature + const buttonHeight = buttonHeights[size] + const containerWidth = + type === 'icon' ? buttonHeight : typeof width === 'string' && /^\d+$/.test(width) ? Number(width) : (width ?? '100%') + + onSuccessRef.current = onSuccess + onErrorRef.current = onError + + useEffect(() => { + if (!clientId) { + return + } + + let isCancelled = false + let retryTimer: ReturnType | undefined + let revealTimer: ReturnType | undefined + let renderedIframe: HTMLIFrameElement | undefined + const loadingStartedAt = performance.now() + + const handleCredential = (credentialResponse: CredentialResponse) => { + if (credentialResponse.credential) { + onSuccessRef.current(credentialResponse) + } else { + onErrorRef.current?.() + } + } + + const handleIframeLoad = () => { + if (!isCancelled && renderedIframe?.src.includes('accounts.google.com/gsi/button')) { + const remainingLoadingTime = Math.max(0, MIN_LOADING_DURATION_MS - (performance.now() - loadingStartedAt)) + revealTimer = setTimeout(() => setReadySignature(renderSignature), remainingLoadingTime) + } + } + + const findRenderedIframe = () => { + const nextIframe = containerRef.current?.querySelector('iframe[src*="accounts.google.com/gsi/button"]') + if (!nextIframe || nextIframe === renderedIframe) { + return + } + + renderedIframe?.removeEventListener('load', handleIframeLoad) + renderedIframe = nextIframe + renderedIframe.addEventListener('load', handleIframeLoad, { once: true }) + } + + const mutationObserver = new MutationObserver(findRenderedIframe) + + const renderButton = () => { + if (isCancelled) { + return + } + + const googleIdentity = getGoogleIdentity() + const container = containerRef.current + if (!googleIdentity || !container) { + retryTimer = setTimeout(renderButton, 50) + return + } + + credentialHandlers.set(buttonState, handleCredential) + if (!initializeGoogleIdentity(googleIdentity, clientId)) { + credentialHandlers.delete(buttonState) + onErrorRef.current?.() + return + } + + mutationObserver.observe(container, { childList: true, subtree: true }) + container.replaceChildren() + googleIdentity.renderButton(container, { + type, + theme, + size, + text, + shape, + logo_alignment, + width, + locale, + click_listener, + state: buttonState + }) + findRenderedIframe() + } + + renderButton() + + return () => { + isCancelled = true + if (retryTimer) { + clearTimeout(retryTimer) + } + if (revealTimer) { + clearTimeout(revealTimer) + } + mutationObserver.disconnect() + renderedIframe?.removeEventListener('load', handleIframeLoad) + if (credentialHandlers.get(buttonState) === handleCredential) { + credentialHandlers.delete(buttonState) + } + } + }, [buttonState, clientId, type, theme, size, text, shape, logo_alignment, width, locale, click_listener, renderSignature]) + + return ( +
+
+ +
+
+ {!isReady && ( + + Loading Google sign-in + + )} +
+ ) +} diff --git a/packages/connect/src/components/SocialLink/SocialLink.tsx b/packages/connect/src/components/SocialLink/SocialLink.tsx index 1d5589efe..bc2663283 100644 --- a/packages/connect/src/components/SocialLink/SocialLink.tsx +++ b/packages/connect/src/components/SocialLink/SocialLink.tsx @@ -1,6 +1,6 @@ import { Button, Divider, PINCodeInput, Spinner, Text, TextInput } from '@0xsequence/design-system' import { type Account } from '@0xsequence/waas' -import { GoogleLogin, type CredentialResponse } from '@react-oauth/google' +import { type CredentialResponse } from '@react-oauth/google' import { ethers } from 'ethers' import { useEffect, useRef, useState, type SetStateAction } from 'react' import AppleSignin from 'react-apple-signin-auth' @@ -9,6 +9,7 @@ import { LocalStorageKey } from '../../constants/localStorage.js' import { useSequenceWaaS } from '../../hooks/useSequenceWaaS.js' import { useStorageItem } from '../../hooks/useStorage.js' import { isAccountAlreadyLinkedError, useEmailAuth } from '../../utils/useEmailAuth.js' +import { GoogleSignInButton } from '../GoogleSignInButton/GoogleSignInButton.js' import { AccountName } from './AccountName.js' @@ -142,7 +143,9 @@ export function SocialLink() {
- {googleClientId && } + {googleClientId && ( + + )} {appleClientId && ( // @ts-ignore Date: Mon, 13 Jul 2026 13:11:04 +0300 Subject: [PATCH 2/4] refactor(connect): harden Google WaaS button backport --- packages/connect/package.json | 11 +- .../src/components/Connect/Connect.tsx | 124 +++--------- .../Connect/SocialConnectorsSection.test.tsx | 83 ++++++++ .../Connect/SocialConnectorsSection.tsx | 81 ++++++++ .../ConnectButton/ConnectButton.tsx | 36 ++-- .../GoogleWaasConnectButton.test.tsx | 133 +++++++++++++ .../GoogleSignInButton.test.tsx | 181 ++++++++++++++++++ .../GoogleSignInButton/GoogleSignInButton.tsx | 30 +-- packages/connect/src/styles.ts | 4 - packages/connect/tsconfig.build.json | 4 + packages/connect/vite.config.ts | 9 + pnpm-lock.yaml | 9 + 12 files changed, 563 insertions(+), 142 deletions(-) create mode 100644 packages/connect/src/components/Connect/SocialConnectorsSection.test.tsx create mode 100644 packages/connect/src/components/Connect/SocialConnectorsSection.tsx create mode 100644 packages/connect/src/components/ConnectButton/GoogleWaasConnectButton.test.tsx create mode 100644 packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.test.tsx create mode 100644 packages/connect/tsconfig.build.json create mode 100644 packages/connect/vite.config.ts diff --git a/packages/connect/package.json b/packages/connect/package.json index 5b20b383e..449e6ee57 100644 --- a/packages/connect/package.json +++ b/packages/connect/package.json @@ -26,16 +26,16 @@ ], "scripts": { "build": "pnpm run writeVersion && pnpm build:clean && pnpm build:css && pnpm build:esm && pnpm build:cjs", - "build:cjs": "tsc --module commonjs --moduleResolution node --verbatimModuleSyntax false --declaration --declarationMap --outDir dist/cjs && echo '{ \"type\": \"commonjs\" }' > dist/cjs/package.json", - "build:esm": "tsc --verbatimModuleSyntax false --declaration --declarationMap --outDir dist/esm", + "build:cjs": "tsc -p tsconfig.build.json --module commonjs --moduleResolution node --verbatimModuleSyntax false --declaration --declarationMap --outDir dist/cjs && echo '{ \"type\": \"commonjs\" }' > dist/cjs/package.json", + "build:esm": "tsc -p tsconfig.build.json --verbatimModuleSyntax false --declaration --declarationMap --outDir dist/esm", "build:css": "node scripts/build-css.js", "build:clean": "rimraf -g ./dist", "build:check": "attw --pack .", "dev": "pnpm run writeVersion && concurrently \"pnpm dev:tsc\" \"pnpm watch-css\"", - "dev:tsc": "tsc --watch --module es2022 --moduleResolution node --declaration --declarationMap --outDir dist/esm", + "dev:tsc": "tsc -p tsconfig.build.json --watch --module es2022 --moduleResolution node --declaration --declarationMap --outDir dist/esm", "watch-css": "node scripts/watch-css.js", "writeVersion": "node scripts/writeVersion.js", - "test": "echo", + "test": "vitest --run", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -72,12 +72,15 @@ "0xsequence": "^2.3.43", "@0xsequence/hooks": "workspace:*", "@tanstack/react-query": "^5.74.11", + "@testing-library/react": "^16.3.0", "@types/uuid": "^9.0.8", "concurrently": "^9.1.2", "ethers": "^6.13.7", + "jsdom": "^25.0.1", "react": "^19.1.0", "react-dom": "^19.1.0", "viem": "^2.44.4", + "vitest": "^2.1.9", "wagmi": "^2.19.5" } } diff --git a/packages/connect/src/components/Connect/Connect.tsx b/packages/connect/src/components/Connect/Connect.tsx index b1d849e34..547d80349 100644 --- a/packages/connect/src/components/Connect/Connect.tsx +++ b/packages/connect/src/components/Connect/Connect.tsx @@ -29,13 +29,14 @@ import { GoogleWaasConnectButton, ShowAllWalletsButton } from '../ConnectButton/index.js' -import type { SequenceConnectProviderProps } from '../SequenceConnectProvider/index.js' import { PoweredByPolygon } from '../PolygonBrand/index.js' +import type { SequenceConnectProviderProps } from '../SequenceConnectProvider/index.js' import { Banner } from './Banner.js' import { ConnectedWallets } from './ConnectedWallets.js' import { EmailWaasVerify } from './EmailWaasVerify.js' import { ExtendedWalletList } from './ExtendedWalletList.js' +import { isGoogleWaasConnector, SocialConnectorsSection, type SocialConnectorButtonOptions } from './SocialConnectorsSection.js' const MAX_ITEM_PER_ROW = 4 export const SEQUENCE_UNIVERSAL_CONNECTOR_NAME = 'Sequence' @@ -380,52 +381,34 @@ export const Connect = (props: ConnectProps) => { const showSocialConnectorSection = socialAuthConnectors.length > 0 const showEmailInputSection = !!emailConnector - const showMoreSocialOptions = socialAuthConnectors.length > MAX_ITEM_PER_ROW const showMoreWalletOptions = walletConnectors.length > MAX_ITEM_PER_ROW - const googleSocialConnector = socialAuthConnectors.find(connector => connector._wallet?.id === 'google-waas') - const socialConnectorsPerRow = showMoreSocialOptions && !descriptiveSocials ? MAX_ITEM_PER_ROW - 1 : socialAuthConnectors.length const walletConnectorsPerRow = showMoreWalletOptions ? MAX_ITEM_PER_ROW - 1 : walletConnectors.length - - const renderSocialConnectorButton = ( - connector: ExtendedConnector, - options?: { isDescriptive?: boolean; googleButtonTheme?: 'filled_blue' | 'outline' } - ) => { - const isDescriptive = options?.isDescriptive ?? descriptiveSocials + const showCompactGoogleButton = + !hideSocialConnectOptions && + showSocialConnectorSection && + !descriptiveSocials && + socialAuthConnectors.some(isGoogleWaasConnector) + + const renderSocialConnectorButton = (connector: ExtendedConnector, options: SocialConnectorButtonOptions) => { + const commonProps = { + connector, + onConnect, + isDescriptive: options.isDescriptive + } switch (connector._wallet.id) { case 'guest-waas': - return ( - - ) + return case 'google-waas': - return ( - - ) + return case 'apple-waas': - return + return case 'epic-waas': - return + return case 'X-waas': - return + return default: - return ( - - ) + return } } @@ -444,7 +427,7 @@ export const Connect = (props: ConnectProps) => { const SEARCHABLE_TRESHOLD = 8 const connectors = showExtendedList === 'social' - ? socialAuthConnectors.filter(connector => connector._wallet?.id !== 'google-waas') + ? socialAuthConnectors.filter(connector => !isGoogleWaasConnector(connector)) : walletConnectors const searchable = connectors.length > SEARCHABLE_TRESHOLD @@ -569,63 +552,16 @@ export const Connect = (props: ConnectProps) => { {showWalletAuthOptionsFirst && !hideExternalConnectOptions && walletConnectors.length > 0 && ( )} -
+
<> - {!hideSocialConnectOptions && - showSocialConnectorSection && - (() => { - const visibleSocialConnectors = socialAuthConnectors.slice(0, socialConnectorsPerRow) - const otherConnectors = visibleSocialConnectors.filter( - connector => connector._wallet?.id !== 'google-waas' - ) - const renderConnectorGroup = (connectors: ExtendedConnector[], includeShowMore = false) => ( -
- {connectors.map(connector => ( -
- {renderSocialConnectorButton(connector)} -
- ))} - {includeShowMore && ( -
- setShowExtendedList('social')} /> -
- )} -
- ) - - if (!googleSocialConnector) { - return renderConnectorGroup(visibleSocialConnectors, showMoreSocialOptions) - } - - if (descriptiveSocials) { - return ( -
- -
- {renderSocialConnectorButton(googleSocialConnector, { - isDescriptive: true, - googleButtonTheme: 'filled_blue' - })} -
- - {renderConnectorGroup(otherConnectors, showMoreSocialOptions)} -
- ) - } - - return ( -
- {renderConnectorGroup(otherConnectors, showMoreSocialOptions)} - - {renderSocialConnectorButton(googleSocialConnector, { - isDescriptive: true, - googleButtonTheme: 'outline' - })} -
- ) - })()} + {!hideSocialConnectOptions && showSocialConnectorSection && ( + setShowExtendedList('social')} + renderConnector={renderSocialConnectorButton} + /> + )} {!hideSocialConnectOptions && showSocialConnectorSection && showEmailInputSection && (
diff --git a/packages/connect/src/components/Connect/SocialConnectorsSection.test.tsx b/packages/connect/src/components/Connect/SocialConnectorsSection.test.tsx new file mode 100644 index 000000000..54a410343 --- /dev/null +++ b/packages/connect/src/components/Connect/SocialConnectorsSection.test.tsx @@ -0,0 +1,83 @@ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { ExtendedConnector } from '../../types.js' + +import { SocialConnectorsSection, type SocialConnectorButtonOptions } from './SocialConnectorsSection.js' + +vi.mock('@0xsequence/design-system', () => ({ + Divider: ({ className }: { className?: string }) =>
+})) + +vi.mock('../ConnectButton/index.js', () => ({ + ShowAllWalletsButton: ({ onClick }: { onClick: () => void }) => +})) + +const connector = (id: string) => + ({ + uid: id, + _wallet: { id } + }) as ExtendedConnector + +const createConnectorRenderer = () => + vi.fn((item: ExtendedConnector, options: SocialConnectorButtonOptions) => ( +
+ )) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('SocialConnectorsSection', () => { + it('preserves the compact row and show-more slot when Google is absent', () => { + const renderConnector = createConnectorRenderer() + const onShowMore = vi.fn() + const connectors = ['one', 'two', 'three', 'four', 'five'].map(connector) + const view = render( + + ) + + expect(renderConnector.mock.calls.map(([item]) => item._wallet.id)).toEqual(['one', 'two', 'three']) + fireEvent.click(view.getByRole('button', { name: 'Show more' })) + expect(onShowMore).toHaveBeenCalledTimes(1) + }) + + it('renders Google separately without displacing the compact connector row', () => { + const renderConnector = createConnectorRenderer() + const connectors = ['guest-waas', 'google-waas', 'apple-waas', 'epic-waas', 'X-waas'].map(connector) + render( + + ) + + expect(renderConnector.mock.calls.map(([item]) => item._wallet.id)).toEqual(['guest-waas', 'apple-waas', 'google-waas']) + expect(renderConnector).toHaveBeenLastCalledWith( + connectors[1], + expect.objectContaining({ isDescriptive: true, googleButtonTheme: 'outline' }) + ) + }) + + it('uses the filled Google button and keeps every connector in descriptive mode', () => { + const renderConnector = createConnectorRenderer() + const connectors = ['guest-waas', 'google-waas', 'apple-waas', 'epic-waas', 'X-waas'].map(connector) + render() + + expect(renderConnector).toHaveBeenCalledWith( + connectors[1], + expect.objectContaining({ isDescriptive: true, googleButtonTheme: 'filled_blue' }) + ) + expect(renderConnector.mock.calls.map(([item]) => item._wallet.id).sort()).toEqual( + connectors.map(item => item._wallet.id).sort() + ) + }) +}) diff --git a/packages/connect/src/components/Connect/SocialConnectorsSection.tsx b/packages/connect/src/components/Connect/SocialConnectorsSection.tsx new file mode 100644 index 000000000..b24f9c6a8 --- /dev/null +++ b/packages/connect/src/components/Connect/SocialConnectorsSection.tsx @@ -0,0 +1,81 @@ +import { Divider } from '@0xsequence/design-system' +import type { ReactNode } from 'react' + +import type { ExtendedConnector } from '../../types.js' +import { ShowAllWalletsButton } from '../ConnectButton/index.js' + +const MAX_ITEMS_PER_ROW = 4 +const GOOGLE_WAAS_CONNECTOR_ID = 'google-waas' + +export interface SocialConnectorButtonOptions { + isDescriptive: boolean + googleButtonTheme?: 'filled_blue' | 'outline' +} + +interface SocialConnectorsSectionProps { + connectors: ExtendedConnector[] + descriptive: boolean + onShowMore: () => void + renderConnector: (connector: ExtendedConnector, options: SocialConnectorButtonOptions) => ReactNode +} + +export const isGoogleWaasConnector = (connector: ExtendedConnector) => connector._wallet?.id === GOOGLE_WAAS_CONNECTOR_ID + +export const SocialConnectorsSection = ({ + connectors, + descriptive, + onShowMore, + renderConnector +}: SocialConnectorsSectionProps) => { + const hasMoreConnectors = connectors.length > MAX_ITEMS_PER_ROW + const visibleConnectorCount = hasMoreConnectors && !descriptive ? MAX_ITEMS_PER_ROW - 1 : connectors.length + const visibleConnectors = connectors.slice(0, visibleConnectorCount) + const googleConnector = connectors.find(isGoogleWaasConnector) + const otherConnectors = visibleConnectors.filter(connector => !isGoogleWaasConnector(connector)) + + const renderConnectorGroup = (groupConnectors: ExtendedConnector[]) => ( +
+ {groupConnectors.map(connector => ( +
+ {renderConnector(connector, { isDescriptive: descriptive })} +
+ ))} + {hasMoreConnectors && ( +
+ +
+ )} +
+ ) + + if (!googleConnector) { + return renderConnectorGroup(visibleConnectors) + } + + if (descriptive) { + return ( +
+ +
+ {renderConnector(googleConnector, { + isDescriptive: true, + googleButtonTheme: 'filled_blue' + })} +
+ + {renderConnectorGroup(otherConnectors)} +
+ ) + } + + return ( +
+ {renderConnectorGroup(otherConnectors)} + + {renderConnector(googleConnector, { + isDescriptive: true, + googleButtonTheme: 'outline' + })} +
+ ) +} diff --git a/packages/connect/src/components/ConnectButton/ConnectButton.tsx b/packages/connect/src/components/ConnectButton/ConnectButton.tsx index 917e7cea2..cc0d7feda 100644 --- a/packages/connect/src/components/ConnectButton/ConnectButton.tsx +++ b/packages/connect/src/components/ConnectButton/ConnectButton.tsx @@ -136,43 +136,29 @@ export const GoogleWaasConnectButton = ( }, []) useEffect(() => { - let hasMeasured = false - let resizeObserver: ResizeObserver | undefined - - const measureInitialWidth = () => { - if (hasMeasured) { - return - } - + const measureWidth = () => { const availableWidth = containerRef.current?.clientWidth ?? 0 if (availableWidth === 0) { return } - hasMeasured = true const nextButtonWidth = Math.min(400, Math.floor(availableWidth)) - setButtonWidth(nextButtonWidth) - resizeObserver?.disconnect() - window.removeEventListener('resize', measureInitialWidth) + setButtonWidth(currentWidth => (currentWidth === nextButtonWidth ? currentWidth : nextButtonWidth)) } - measureInitialWidth() + measureWidth() - if (!hasMeasured) { - if (typeof ResizeObserver === 'undefined') { - window.addEventListener('resize', measureInitialWidth) - } else { - resizeObserver = new ResizeObserver(measureInitialWidth) - if (containerRef.current) { - resizeObserver.observe(containerRef.current) - } - } + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measureWidth) + return () => window.removeEventListener('resize', measureWidth) } - return () => { - resizeObserver?.disconnect() - window.removeEventListener('resize', measureInitialWidth) + const resizeObserver = new ResizeObserver(measureWidth) + if (containerRef.current) { + resizeObserver.observe(containerRef.current) } + + return () => resizeObserver.disconnect() }, []) const useIconButton = buttonWidth < GOOGLE_STANDARD_BUTTON_MIN_WIDTH diff --git a/packages/connect/src/components/ConnectButton/GoogleWaasConnectButton.test.tsx b/packages/connect/src/components/ConnectButton/GoogleWaasConnectButton.test.tsx new file mode 100644 index 000000000..55f566cdd --- /dev/null +++ b/packages/connect/src/components/ConnectButton/GoogleWaasConnectButton.test.tsx @@ -0,0 +1,133 @@ +import type { CredentialResponse } from '@react-oauth/google' +import { act, cleanup, render } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { LocalStorageKey } from '../../constants/localStorage.js' +import type { ExtendedConnector } from '../../types.js' + +import { GoogleWaasConnectButton } from './ConnectButton.js' + +interface MockGoogleButtonProps { + clientId: string + width?: string + type?: 'standard' | 'icon' + theme?: string + onSuccess: (response: CredentialResponse) => void +} + +const mocks = vi.hoisted(() => ({ + googleButtonProps: undefined as MockGoogleButtonProps | undefined, + setItem: vi.fn() +})) + +vi.mock('@0xsequence/design-system', () => { + const Passthrough = ({ children }: { children?: ReactNode }) => <>{children} + return { + Card: Passthrough, + ContextMenuIcon: () => null, + Text: Passthrough, + Tooltip: Passthrough, + useTheme: () => ({ theme: 'dark' }) + } +}) + +vi.mock('react-apple-signin-auth', () => ({ + default: () => null, + appleAuthHelpers: {} +})) + +vi.mock('../../connectors/X/XAuth.js', () => ({ getXIdToken: vi.fn() })) +vi.mock('../../hooks/useStorage.js', () => ({ + useStorage: () => ({ setItem: mocks.setItem }), + useStorageItem: () => ({ data: undefined }) +})) +vi.mock('../GoogleSignInButton/GoogleSignInButton.js', () => ({ + GoogleSignInButton: (props: MockGoogleButtonProps) => { + mocks.googleButtonProps = props + return
+ } +})) + +const connector = { + uid: 'google-waas', + params: { googleClientId: 'google-client-id' }, + _wallet: { id: 'google-waas' } +} as unknown as ExtendedConnector + +let availableWidth = 358 +let resizeCallback: ResizeObserverCallback | undefined +const disconnectSpy = vi.fn() + +beforeEach(() => { + availableWidth = 358 + resizeCallback = undefined + mocks.googleButtonProps = undefined + mocks.setItem.mockReset() + disconnectSpy.mockReset() + + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => availableWidth) + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback + } + observe() {} + disconnect() { + disconnectSpy() + } + } + ) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('GoogleWaasConnectButton', () => { + it('stores the credential before connecting', () => { + const onConnect = vi.fn() + render() + + act(() => mocks.googleButtonProps?.onSuccess({ credential: 'google-token' })) + + expect(mocks.setItem).toHaveBeenCalledWith(LocalStorageKey.WaasGoogleIdToken, 'google-token') + expect(onConnect).toHaveBeenCalledWith(connector) + expect(mocks.setItem.mock.invocationCallOrder[0]).toBeLessThan(onConnect.mock.invocationCallOrder[0]) + }) + + it('does not connect without a credential or after unmount', () => { + const onConnect = vi.fn() + const view = render() + const onSuccess = mocks.googleButtonProps?.onSuccess + + act(() => onSuccess?.({})) + view.unmount() + act(() => onSuccess?.({ credential: 'stale-token' })) + + expect(mocks.setItem).not.toHaveBeenCalled() + expect(onConnect).not.toHaveBeenCalled() + }) + + it('updates the official button variant as its container resizes', () => { + const view = render() + + expect(mocks.googleButtonProps).toEqual( + expect.objectContaining({ clientId: 'google-client-id', type: 'standard', width: '358', theme: 'outline_dark' }) + ) + + availableWidth = 200 + act(() => resizeCallback?.([], {} as ResizeObserver)) + expect(mocks.googleButtonProps).toEqual(expect.objectContaining({ type: 'icon', width: undefined })) + + availableWidth = 500 + act(() => resizeCallback?.([], {} as ResizeObserver)) + expect(mocks.googleButtonProps).toEqual(expect.objectContaining({ type: 'standard', width: '400' })) + + view.unmount() + expect(disconnectSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.test.tsx b/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.test.tsx new file mode 100644 index 000000000..f6489a092 --- /dev/null +++ b/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.test.tsx @@ -0,0 +1,181 @@ +import type { CredentialResponse, GsiButtonConfiguration, IdConfiguration } from '@react-oauth/google' +import { act, cleanup, render } from '@testing-library/react' +import { StrictMode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { GoogleSignInButton } from './GoogleSignInButton.js' + +vi.mock('@0xsequence/design-system', () => ({ + Skeleton: ({ className }: { className?: string }) =>
+})) + +interface RoutedCredentialResponse extends CredentialResponse { + state?: string +} + +interface TestButtonConfiguration extends GsiButtonConfiguration { + state?: string +} + +const installGoogleIdentity = () => { + let credentialCallback: IdConfiguration['callback'] + const initialize = vi.fn((config: IdConfiguration) => { + credentialCallback = config.callback + }) + const renderButton = vi.fn((container: HTMLElement, _options: TestButtonConfiguration) => { + const iframe = document.createElement('iframe') + iframe.src = 'https://accounts.google.com/gsi/button' + container.append(iframe) + }) + + ;(window as any).google = { accounts: { id: { initialize, renderButton } } } + + return { + initialize, + renderButton, + respond: (response: RoutedCredentialResponse) => credentialCallback?.(response) + } +} + +const renderedButtonState = (renderButton: ReturnType['renderButton'], callIndex: number) => + renderButton.mock.calls[callIndex][1].state + +afterEach(() => { + cleanup() + delete (window as any).google + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('GoogleSignInButton', () => { + it('routes credentials to the button that initiated sign-in', () => { + const googleIdentity = installGoogleIdentity() + const firstOnSuccess = vi.fn() + const secondOnSuccess = vi.fn() + + render( + <> + + + + ) + + expect(googleIdentity.initialize).toHaveBeenCalledTimes(1) + expect(googleIdentity.renderButton).toHaveBeenCalledTimes(2) + + const firstState = renderedButtonState(googleIdentity.renderButton, 0) + const secondState = renderedButtonState(googleIdentity.renderButton, 1) + expect(firstState).toBeTruthy() + expect(secondState).toBeTruthy() + expect(firstState).not.toBe(secondState) + + act(() => googleIdentity.respond({ credential: 'first-token', state: firstState })) + expect(firstOnSuccess).toHaveBeenCalledWith(expect.objectContaining({ credential: 'first-token' })) + expect(secondOnSuccess).not.toHaveBeenCalled() + + act(() => googleIdentity.respond({ credential: 'second-token', state: secondState })) + expect(secondOnSuccess).toHaveBeenCalledWith(expect.objectContaining({ credential: 'second-token' })) + }) + + it('keeps callback routing intact through StrictMode setup and cleanup', () => { + const googleIdentity = installGoogleIdentity() + const onSuccess = vi.fn() + + render( + + + + ) + + const latestCall = googleIdentity.renderButton.mock.calls.length - 1 + const buttonState = renderedButtonState(googleIdentity.renderButton, latestCall) + + act(() => googleIdentity.respond({ credential: 'token', state: buttonState })) + expect(onSuccess).toHaveBeenCalledTimes(1) + }) + + it('removes callback routing when the button unmounts', () => { + const googleIdentity = installGoogleIdentity() + const onSuccess = vi.fn() + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const view = render() + const buttonState = renderedButtonState(googleIdentity.renderButton, 0) + + view.unmount() + act(() => googleIdentity.respond({ credential: 'stale-token', state: buttonState })) + + expect(onSuccess).not.toHaveBeenCalled() + expect(consoleError).toHaveBeenCalledWith('Unable to match the Google credential response to the button that initiated it.') + }) + + it('waits for Google Identity Services to become available', () => { + vi.useFakeTimers() + const onSuccess = vi.fn() + render() + + const googleIdentity = installGoogleIdentity() + act(() => vi.advanceTimersByTime(50)) + + expect(googleIdentity.initialize).toHaveBeenCalledTimes(1) + expect(googleIdentity.renderButton).toHaveBeenCalledTimes(1) + }) + + it('keeps the Google iframe hidden until it has loaded', () => { + vi.useFakeTimers() + installGoogleIdentity() + const view = render() + const iframe = view.container.querySelector('iframe') + const buttonContainer = iframe?.parentElement + + expect(iframe).not.toBeNull() + expect(buttonContainer?.style.visibility).toBe('hidden') + expect(view.getByRole('status').textContent).toBe('Loading Google sign-in') + + act(() => { + iframe?.dispatchEvent(new Event('load')) + vi.advanceTimersByTime(600) + }) + + expect(buttonContainer?.style.visibility).toBe('visible') + expect(view.queryByRole('status')).toBeNull() + }) + + it('replaces and cleans the reveal timer when GIS replaces its iframe', async () => { + vi.useFakeTimers() + installGoogleIdentity() + const view = render() + const firstIframe = view.container.querySelector('iframe') + const buttonContainer = firstIframe?.parentElement + + act(() => firstIframe?.dispatchEvent(new Event('load'))) + expect(vi.getTimerCount()).toBe(1) + + const replacementIframe = document.createElement('iframe') + replacementIframe.src = 'https://accounts.google.com/gsi/button' + await act(async () => { + buttonContainer?.replaceChildren(replacementIframe) + await Promise.resolve() + }) + act(() => replacementIframe.dispatchEvent(new Event('load'))) + + expect(vi.getTimerCount()).toBe(1) + view.unmount() + expect(vi.getTimerCount()).toBe(0) + }) + + it('rejects multiple client IDs for one Google Identity Services instance', () => { + const googleIdentity = installGoogleIdentity() + const secondOnError = vi.fn() + vi.spyOn(console, 'error').mockImplementation(() => undefined) + + render( + <> + + + + ) + + expect(googleIdentity.renderButton).toHaveBeenCalledTimes(1) + expect(secondOnError).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.tsx b/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.tsx index 3458f3995..d3189e941 100644 --- a/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.tsx +++ b/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.tsx @@ -18,7 +18,7 @@ interface GoogleIdentityApi { renderButton: (parent: HTMLElement, options: GoogleButtonConfiguration) => void } -interface GoogleSignInButtonProps extends Omit { +interface GoogleSignInButtonProps extends Omit { clientId: string theme?: GoogleButtonTheme onSuccess: (credentialResponse: CredentialResponse) => void @@ -80,8 +80,7 @@ export const GoogleSignInButton = ({ shape, logo_alignment, width, - locale, - click_listener + locale }: GoogleSignInButtonProps) => { const containerRef = useRef(null) const onSuccessRef = useRef(onSuccess) @@ -103,6 +102,7 @@ export const GoogleSignInButton = ({ useEffect(() => { if (!clientId) { + containerRef.current?.replaceChildren() return } @@ -123,6 +123,9 @@ export const GoogleSignInButton = ({ const handleIframeLoad = () => { if (!isCancelled && renderedIframe?.src.includes('accounts.google.com/gsi/button')) { const remainingLoadingTime = Math.max(0, MIN_LOADING_DURATION_MS - (performance.now() - loadingStartedAt)) + if (revealTimer) { + clearTimeout(revealTimer) + } revealTimer = setTimeout(() => setReadySignature(renderSignature), remainingLoadingTime) } } @@ -152,6 +155,7 @@ export const GoogleSignInButton = ({ return } + container.replaceChildren() credentialHandlers.set(buttonState, handleCredential) if (!initializeGoogleIdentity(googleIdentity, clientId)) { credentialHandlers.delete(buttonState) @@ -160,7 +164,6 @@ export const GoogleSignInButton = ({ } mutationObserver.observe(container, { childList: true, subtree: true }) - container.replaceChildren() googleIdentity.renderButton(container, { type, theme, @@ -170,7 +173,6 @@ export const GoogleSignInButton = ({ logo_alignment, width, locale, - click_listener, state: buttonState }) findRenderedIframe() @@ -188,27 +190,25 @@ export const GoogleSignInButton = ({ } mutationObserver.disconnect() renderedIframe?.removeEventListener('load', handleIframeLoad) + containerRef.current?.replaceChildren() if (credentialHandlers.get(buttonState) === handleCredential) { credentialHandlers.delete(buttonState) } } - }, [buttonState, clientId, type, theme, size, text, shape, logo_alignment, width, locale, click_listener, renderSignature]) + }, [buttonState, clientId, type, theme, size, text, shape, logo_alignment, width, locale, renderSignature]) return (
-
- -
+ {!isReady && ( +
+ +
+ )}
{!isReady && ( diff --git a/packages/connect/src/styles.ts b/packages/connect/src/styles.ts index 3963611cf..3860292b9 100644 --- a/packages/connect/src/styles.ts +++ b/packages/connect/src/styles.ts @@ -1490,10 +1490,6 @@ export const styles = String.raw` --tw-duration: 100ms; transition-duration: 100ms; } - .duration-150 { - --tw-duration: 150ms; - transition-duration: 150ms; - } .duration-200 { --tw-duration: 200ms; transition-duration: 200ms; diff --git a/packages/connect/tsconfig.build.json b/packages/connect/tsconfig.build.json new file mode 100644 index 000000000..07310ec76 --- /dev/null +++ b/packages/connect/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["./src/**/*.test.ts", "./src/**/*.test.tsx"] +} diff --git a/packages/connect/vite.config.ts b/packages/connect/vite.config.ts new file mode 100644 index 000000000..3efda34c6 --- /dev/null +++ b/packages/connect/vite.config.ts @@ -0,0 +1,9 @@ +/// +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'jsdom', + exclude: ['**/node_modules/**', '**/dist/**'] + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f13cf62cd..76a76e338 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -375,6 +375,9 @@ importers: '@tanstack/react-query': specifier: ^5.74.11 version: 5.80.5(react@19.1.0) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@types/uuid': specifier: ^9.0.8 version: 9.0.8 @@ -384,6 +387,9 @@ importers: ethers: specifier: ^6.13.7 version: 6.14.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) + jsdom: + specifier: ^25.0.1 + version: 25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) react: specifier: ^19.1.0 version: 19.1.0 @@ -393,6 +399,9 @@ importers: viem: specifier: ^2.44.4 version: 2.44.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.7.5)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.1)(msw@2.9.0(@types/node@22.7.5)(typescript@5.8.3)) wagmi: specifier: ^2.19.5 version: 2.19.5(@tanstack/query-core@5.90.19)(@tanstack/react-query@5.80.5(react@19.1.0))(@types/react@19.1.6)(bufferutil@4.0.9)(react@19.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.44.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) From 07d7a2a41acc1442c61e382ccf12ac2a3bbab6df Mon Sep 17 00:00:00 2001 From: Alexander Kolberg Date: Mon, 13 Jul 2026 13:20:21 +0300 Subject: [PATCH 3/4] chore(connect): trim backport test infrastructure --- packages/connect/package.json | 11 +- .../Connect/SocialConnectorsSection.test.tsx | 83 -------- .../GoogleWaasConnectButton.test.tsx | 133 ------------- .../GoogleSignInButton.test.tsx | 181 ------------------ packages/connect/tsconfig.build.json | 4 - packages/connect/vite.config.ts | 9 - pnpm-lock.yaml | 9 - 7 files changed, 4 insertions(+), 426 deletions(-) delete mode 100644 packages/connect/src/components/Connect/SocialConnectorsSection.test.tsx delete mode 100644 packages/connect/src/components/ConnectButton/GoogleWaasConnectButton.test.tsx delete mode 100644 packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.test.tsx delete mode 100644 packages/connect/tsconfig.build.json delete mode 100644 packages/connect/vite.config.ts diff --git a/packages/connect/package.json b/packages/connect/package.json index 449e6ee57..5b20b383e 100644 --- a/packages/connect/package.json +++ b/packages/connect/package.json @@ -26,16 +26,16 @@ ], "scripts": { "build": "pnpm run writeVersion && pnpm build:clean && pnpm build:css && pnpm build:esm && pnpm build:cjs", - "build:cjs": "tsc -p tsconfig.build.json --module commonjs --moduleResolution node --verbatimModuleSyntax false --declaration --declarationMap --outDir dist/cjs && echo '{ \"type\": \"commonjs\" }' > dist/cjs/package.json", - "build:esm": "tsc -p tsconfig.build.json --verbatimModuleSyntax false --declaration --declarationMap --outDir dist/esm", + "build:cjs": "tsc --module commonjs --moduleResolution node --verbatimModuleSyntax false --declaration --declarationMap --outDir dist/cjs && echo '{ \"type\": \"commonjs\" }' > dist/cjs/package.json", + "build:esm": "tsc --verbatimModuleSyntax false --declaration --declarationMap --outDir dist/esm", "build:css": "node scripts/build-css.js", "build:clean": "rimraf -g ./dist", "build:check": "attw --pack .", "dev": "pnpm run writeVersion && concurrently \"pnpm dev:tsc\" \"pnpm watch-css\"", - "dev:tsc": "tsc -p tsconfig.build.json --watch --module es2022 --moduleResolution node --declaration --declarationMap --outDir dist/esm", + "dev:tsc": "tsc --watch --module es2022 --moduleResolution node --declaration --declarationMap --outDir dist/esm", "watch-css": "node scripts/watch-css.js", "writeVersion": "node scripts/writeVersion.js", - "test": "vitest --run", + "test": "echo", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -72,15 +72,12 @@ "0xsequence": "^2.3.43", "@0xsequence/hooks": "workspace:*", "@tanstack/react-query": "^5.74.11", - "@testing-library/react": "^16.3.0", "@types/uuid": "^9.0.8", "concurrently": "^9.1.2", "ethers": "^6.13.7", - "jsdom": "^25.0.1", "react": "^19.1.0", "react-dom": "^19.1.0", "viem": "^2.44.4", - "vitest": "^2.1.9", "wagmi": "^2.19.5" } } diff --git a/packages/connect/src/components/Connect/SocialConnectorsSection.test.tsx b/packages/connect/src/components/Connect/SocialConnectorsSection.test.tsx deleted file mode 100644 index 54a410343..000000000 --- a/packages/connect/src/components/Connect/SocialConnectorsSection.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { cleanup, fireEvent, render } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import type { ExtendedConnector } from '../../types.js' - -import { SocialConnectorsSection, type SocialConnectorButtonOptions } from './SocialConnectorsSection.js' - -vi.mock('@0xsequence/design-system', () => ({ - Divider: ({ className }: { className?: string }) =>
-})) - -vi.mock('../ConnectButton/index.js', () => ({ - ShowAllWalletsButton: ({ onClick }: { onClick: () => void }) => -})) - -const connector = (id: string) => - ({ - uid: id, - _wallet: { id } - }) as ExtendedConnector - -const createConnectorRenderer = () => - vi.fn((item: ExtendedConnector, options: SocialConnectorButtonOptions) => ( -
- )) - -afterEach(() => { - cleanup() - vi.restoreAllMocks() -}) - -describe('SocialConnectorsSection', () => { - it('preserves the compact row and show-more slot when Google is absent', () => { - const renderConnector = createConnectorRenderer() - const onShowMore = vi.fn() - const connectors = ['one', 'two', 'three', 'four', 'five'].map(connector) - const view = render( - - ) - - expect(renderConnector.mock.calls.map(([item]) => item._wallet.id)).toEqual(['one', 'two', 'three']) - fireEvent.click(view.getByRole('button', { name: 'Show more' })) - expect(onShowMore).toHaveBeenCalledTimes(1) - }) - - it('renders Google separately without displacing the compact connector row', () => { - const renderConnector = createConnectorRenderer() - const connectors = ['guest-waas', 'google-waas', 'apple-waas', 'epic-waas', 'X-waas'].map(connector) - render( - - ) - - expect(renderConnector.mock.calls.map(([item]) => item._wallet.id)).toEqual(['guest-waas', 'apple-waas', 'google-waas']) - expect(renderConnector).toHaveBeenLastCalledWith( - connectors[1], - expect.objectContaining({ isDescriptive: true, googleButtonTheme: 'outline' }) - ) - }) - - it('uses the filled Google button and keeps every connector in descriptive mode', () => { - const renderConnector = createConnectorRenderer() - const connectors = ['guest-waas', 'google-waas', 'apple-waas', 'epic-waas', 'X-waas'].map(connector) - render() - - expect(renderConnector).toHaveBeenCalledWith( - connectors[1], - expect.objectContaining({ isDescriptive: true, googleButtonTheme: 'filled_blue' }) - ) - expect(renderConnector.mock.calls.map(([item]) => item._wallet.id).sort()).toEqual( - connectors.map(item => item._wallet.id).sort() - ) - }) -}) diff --git a/packages/connect/src/components/ConnectButton/GoogleWaasConnectButton.test.tsx b/packages/connect/src/components/ConnectButton/GoogleWaasConnectButton.test.tsx deleted file mode 100644 index 55f566cdd..000000000 --- a/packages/connect/src/components/ConnectButton/GoogleWaasConnectButton.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import type { CredentialResponse } from '@react-oauth/google' -import { act, cleanup, render } from '@testing-library/react' -import type { ReactNode } from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { LocalStorageKey } from '../../constants/localStorage.js' -import type { ExtendedConnector } from '../../types.js' - -import { GoogleWaasConnectButton } from './ConnectButton.js' - -interface MockGoogleButtonProps { - clientId: string - width?: string - type?: 'standard' | 'icon' - theme?: string - onSuccess: (response: CredentialResponse) => void -} - -const mocks = vi.hoisted(() => ({ - googleButtonProps: undefined as MockGoogleButtonProps | undefined, - setItem: vi.fn() -})) - -vi.mock('@0xsequence/design-system', () => { - const Passthrough = ({ children }: { children?: ReactNode }) => <>{children} - return { - Card: Passthrough, - ContextMenuIcon: () => null, - Text: Passthrough, - Tooltip: Passthrough, - useTheme: () => ({ theme: 'dark' }) - } -}) - -vi.mock('react-apple-signin-auth', () => ({ - default: () => null, - appleAuthHelpers: {} -})) - -vi.mock('../../connectors/X/XAuth.js', () => ({ getXIdToken: vi.fn() })) -vi.mock('../../hooks/useStorage.js', () => ({ - useStorage: () => ({ setItem: mocks.setItem }), - useStorageItem: () => ({ data: undefined }) -})) -vi.mock('../GoogleSignInButton/GoogleSignInButton.js', () => ({ - GoogleSignInButton: (props: MockGoogleButtonProps) => { - mocks.googleButtonProps = props - return
- } -})) - -const connector = { - uid: 'google-waas', - params: { googleClientId: 'google-client-id' }, - _wallet: { id: 'google-waas' } -} as unknown as ExtendedConnector - -let availableWidth = 358 -let resizeCallback: ResizeObserverCallback | undefined -const disconnectSpy = vi.fn() - -beforeEach(() => { - availableWidth = 358 - resizeCallback = undefined - mocks.googleButtonProps = undefined - mocks.setItem.mockReset() - disconnectSpy.mockReset() - - vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => availableWidth) - vi.stubGlobal( - 'ResizeObserver', - class { - constructor(callback: ResizeObserverCallback) { - resizeCallback = callback - } - observe() {} - disconnect() { - disconnectSpy() - } - } - ) -}) - -afterEach(() => { - cleanup() - vi.restoreAllMocks() - vi.unstubAllGlobals() -}) - -describe('GoogleWaasConnectButton', () => { - it('stores the credential before connecting', () => { - const onConnect = vi.fn() - render() - - act(() => mocks.googleButtonProps?.onSuccess({ credential: 'google-token' })) - - expect(mocks.setItem).toHaveBeenCalledWith(LocalStorageKey.WaasGoogleIdToken, 'google-token') - expect(onConnect).toHaveBeenCalledWith(connector) - expect(mocks.setItem.mock.invocationCallOrder[0]).toBeLessThan(onConnect.mock.invocationCallOrder[0]) - }) - - it('does not connect without a credential or after unmount', () => { - const onConnect = vi.fn() - const view = render() - const onSuccess = mocks.googleButtonProps?.onSuccess - - act(() => onSuccess?.({})) - view.unmount() - act(() => onSuccess?.({ credential: 'stale-token' })) - - expect(mocks.setItem).not.toHaveBeenCalled() - expect(onConnect).not.toHaveBeenCalled() - }) - - it('updates the official button variant as its container resizes', () => { - const view = render() - - expect(mocks.googleButtonProps).toEqual( - expect.objectContaining({ clientId: 'google-client-id', type: 'standard', width: '358', theme: 'outline_dark' }) - ) - - availableWidth = 200 - act(() => resizeCallback?.([], {} as ResizeObserver)) - expect(mocks.googleButtonProps).toEqual(expect.objectContaining({ type: 'icon', width: undefined })) - - availableWidth = 500 - act(() => resizeCallback?.([], {} as ResizeObserver)) - expect(mocks.googleButtonProps).toEqual(expect.objectContaining({ type: 'standard', width: '400' })) - - view.unmount() - expect(disconnectSpy).toHaveBeenCalledTimes(1) - }) -}) diff --git a/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.test.tsx b/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.test.tsx deleted file mode 100644 index f6489a092..000000000 --- a/packages/connect/src/components/GoogleSignInButton/GoogleSignInButton.test.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import type { CredentialResponse, GsiButtonConfiguration, IdConfiguration } from '@react-oauth/google' -import { act, cleanup, render } from '@testing-library/react' -import { StrictMode } from 'react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { GoogleSignInButton } from './GoogleSignInButton.js' - -vi.mock('@0xsequence/design-system', () => ({ - Skeleton: ({ className }: { className?: string }) =>
-})) - -interface RoutedCredentialResponse extends CredentialResponse { - state?: string -} - -interface TestButtonConfiguration extends GsiButtonConfiguration { - state?: string -} - -const installGoogleIdentity = () => { - let credentialCallback: IdConfiguration['callback'] - const initialize = vi.fn((config: IdConfiguration) => { - credentialCallback = config.callback - }) - const renderButton = vi.fn((container: HTMLElement, _options: TestButtonConfiguration) => { - const iframe = document.createElement('iframe') - iframe.src = 'https://accounts.google.com/gsi/button' - container.append(iframe) - }) - - ;(window as any).google = { accounts: { id: { initialize, renderButton } } } - - return { - initialize, - renderButton, - respond: (response: RoutedCredentialResponse) => credentialCallback?.(response) - } -} - -const renderedButtonState = (renderButton: ReturnType['renderButton'], callIndex: number) => - renderButton.mock.calls[callIndex][1].state - -afterEach(() => { - cleanup() - delete (window as any).google - vi.restoreAllMocks() - vi.useRealTimers() -}) - -describe('GoogleSignInButton', () => { - it('routes credentials to the button that initiated sign-in', () => { - const googleIdentity = installGoogleIdentity() - const firstOnSuccess = vi.fn() - const secondOnSuccess = vi.fn() - - render( - <> - - - - ) - - expect(googleIdentity.initialize).toHaveBeenCalledTimes(1) - expect(googleIdentity.renderButton).toHaveBeenCalledTimes(2) - - const firstState = renderedButtonState(googleIdentity.renderButton, 0) - const secondState = renderedButtonState(googleIdentity.renderButton, 1) - expect(firstState).toBeTruthy() - expect(secondState).toBeTruthy() - expect(firstState).not.toBe(secondState) - - act(() => googleIdentity.respond({ credential: 'first-token', state: firstState })) - expect(firstOnSuccess).toHaveBeenCalledWith(expect.objectContaining({ credential: 'first-token' })) - expect(secondOnSuccess).not.toHaveBeenCalled() - - act(() => googleIdentity.respond({ credential: 'second-token', state: secondState })) - expect(secondOnSuccess).toHaveBeenCalledWith(expect.objectContaining({ credential: 'second-token' })) - }) - - it('keeps callback routing intact through StrictMode setup and cleanup', () => { - const googleIdentity = installGoogleIdentity() - const onSuccess = vi.fn() - - render( - - - - ) - - const latestCall = googleIdentity.renderButton.mock.calls.length - 1 - const buttonState = renderedButtonState(googleIdentity.renderButton, latestCall) - - act(() => googleIdentity.respond({ credential: 'token', state: buttonState })) - expect(onSuccess).toHaveBeenCalledTimes(1) - }) - - it('removes callback routing when the button unmounts', () => { - const googleIdentity = installGoogleIdentity() - const onSuccess = vi.fn() - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const view = render() - const buttonState = renderedButtonState(googleIdentity.renderButton, 0) - - view.unmount() - act(() => googleIdentity.respond({ credential: 'stale-token', state: buttonState })) - - expect(onSuccess).not.toHaveBeenCalled() - expect(consoleError).toHaveBeenCalledWith('Unable to match the Google credential response to the button that initiated it.') - }) - - it('waits for Google Identity Services to become available', () => { - vi.useFakeTimers() - const onSuccess = vi.fn() - render() - - const googleIdentity = installGoogleIdentity() - act(() => vi.advanceTimersByTime(50)) - - expect(googleIdentity.initialize).toHaveBeenCalledTimes(1) - expect(googleIdentity.renderButton).toHaveBeenCalledTimes(1) - }) - - it('keeps the Google iframe hidden until it has loaded', () => { - vi.useFakeTimers() - installGoogleIdentity() - const view = render() - const iframe = view.container.querySelector('iframe') - const buttonContainer = iframe?.parentElement - - expect(iframe).not.toBeNull() - expect(buttonContainer?.style.visibility).toBe('hidden') - expect(view.getByRole('status').textContent).toBe('Loading Google sign-in') - - act(() => { - iframe?.dispatchEvent(new Event('load')) - vi.advanceTimersByTime(600) - }) - - expect(buttonContainer?.style.visibility).toBe('visible') - expect(view.queryByRole('status')).toBeNull() - }) - - it('replaces and cleans the reveal timer when GIS replaces its iframe', async () => { - vi.useFakeTimers() - installGoogleIdentity() - const view = render() - const firstIframe = view.container.querySelector('iframe') - const buttonContainer = firstIframe?.parentElement - - act(() => firstIframe?.dispatchEvent(new Event('load'))) - expect(vi.getTimerCount()).toBe(1) - - const replacementIframe = document.createElement('iframe') - replacementIframe.src = 'https://accounts.google.com/gsi/button' - await act(async () => { - buttonContainer?.replaceChildren(replacementIframe) - await Promise.resolve() - }) - act(() => replacementIframe.dispatchEvent(new Event('load'))) - - expect(vi.getTimerCount()).toBe(1) - view.unmount() - expect(vi.getTimerCount()).toBe(0) - }) - - it('rejects multiple client IDs for one Google Identity Services instance', () => { - const googleIdentity = installGoogleIdentity() - const secondOnError = vi.fn() - vi.spyOn(console, 'error').mockImplementation(() => undefined) - - render( - <> - - - - ) - - expect(googleIdentity.renderButton).toHaveBeenCalledTimes(1) - expect(secondOnError).toHaveBeenCalledTimes(1) - }) -}) diff --git a/packages/connect/tsconfig.build.json b/packages/connect/tsconfig.build.json deleted file mode 100644 index 07310ec76..000000000 --- a/packages/connect/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["./src/**/*.test.ts", "./src/**/*.test.tsx"] -} diff --git a/packages/connect/vite.config.ts b/packages/connect/vite.config.ts deleted file mode 100644 index 3efda34c6..000000000 --- a/packages/connect/vite.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -/// -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - environment: 'jsdom', - exclude: ['**/node_modules/**', '**/dist/**'] - } -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76a76e338..f13cf62cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -375,9 +375,6 @@ importers: '@tanstack/react-query': specifier: ^5.74.11 version: 5.80.5(react@19.1.0) - '@testing-library/react': - specifier: ^16.3.0 - version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@types/uuid': specifier: ^9.0.8 version: 9.0.8 @@ -387,9 +384,6 @@ importers: ethers: specifier: ^6.13.7 version: 6.14.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) - jsdom: - specifier: ^25.0.1 - version: 25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) react: specifier: ^19.1.0 version: 19.1.0 @@ -399,9 +393,6 @@ importers: viem: specifier: ^2.44.4 version: 2.44.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - vitest: - specifier: ^2.1.9 - version: 2.1.9(@types/node@22.7.5)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.1)(msw@2.9.0(@types/node@22.7.5)(typescript@5.8.3)) wagmi: specifier: ^2.19.5 version: 2.19.5(@tanstack/query-core@5.90.19)(@tanstack/react-query@5.80.5(react@19.1.0))(@types/react@19.1.6)(bufferutil@4.0.9)(react@19.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.44.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) From 25cb1c292fe0fd29df354f11cac72cc90f62c613 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Mon, 13 Jul 2026 13:53:27 +0300 Subject: [PATCH 4/4] fix(connect): complete v5 Google button backport --- examples/react/src/App.tsx | 25 +++++++++++++++++-- examples/react/src/components/Homepage.tsx | 24 ++++++++++++++++-- .../GoogleSignInButton/GoogleSignInButton.tsx | 15 ++++++----- packages/connect/src/styles.ts | 4 +++ 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index fa735287f..2894ad2db 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -1,6 +1,7 @@ import { SequenceCheckoutProvider } from '@0xsequence/checkout' import { SequenceConnect } from '@0xsequence/connect' import { SequenceWalletProvider } from '@0xsequence/wallet-widget' +import { useMemo, useState } from 'react' import { BrowserRouter, Route, Routes } from 'react-router-dom' import { Homepage } from './components/Homepage' @@ -10,13 +11,33 @@ import { XAuthCallback } from './components/XAuthCallback' import { checkoutConfig, config } from './config' export const App = () => { + const [useFullWidthSocials, setUseFullWidthSocials] = useState(config.connectConfig.signIn?.descriptiveSocials ?? false) + const demoConfig = useMemo( + () => ({ + ...config, + connectConfig: { + ...config.connectConfig, + signIn: { + ...config.connectConfig.signIn, + descriptiveSocials: useFullWidthSocials + } + } + }), + [useFullWidthSocials] + ) + return ( - + - } /> + + } + /> } /> } /> } /> diff --git a/examples/react/src/components/Homepage.tsx b/examples/react/src/components/Homepage.tsx index 463c77b15..34a68ae15 100644 --- a/examples/react/src/components/Homepage.tsx +++ b/examples/react/src/components/Homepage.tsx @@ -1,5 +1,5 @@ import { useOpenConnectModal, useWallets, WalletType } from '@0xsequence/connect' -import { Button, Card, CheckmarkIcon, Image, Text } from '@0xsequence/design-system' +import { Button, Card, CheckmarkIcon, Image, Switch, Text } from '@0xsequence/design-system' import { clsx } from 'clsx' import { Footer } from 'example-shared-components' import { Link } from 'react-router-dom' @@ -10,7 +10,12 @@ import { Connected } from './Connected' const searchParams = new URLSearchParams(location.search) const walletType: WalletType = searchParams.get('type') === 'universal' ? 'universal' : 'waas' -export const Homepage = () => { +interface HomepageProps { + useFullWidthSocials: boolean + onUseFullWidthSocialsChange: (useFullWidthSocials: boolean) => void +} + +export const Homepage = ({ useFullWidthSocials, onUseFullWidthSocialsChange }: HomepageProps) => { const { wallets } = useWallets() const { setOpenConnectModal } = useOpenConnectModal() @@ -40,6 +45,21 @@ export const Homepage = () => {
+ +
- {!isReady && ( -
- -
- )} +
+ +
{!isReady && ( diff --git a/packages/connect/src/styles.ts b/packages/connect/src/styles.ts index 3860292b9..3963611cf 100644 --- a/packages/connect/src/styles.ts +++ b/packages/connect/src/styles.ts @@ -1490,6 +1490,10 @@ export const styles = String.raw` --tw-duration: 100ms; transition-duration: 100ms; } + .duration-150 { + --tw-duration: 150ms; + transition-duration: 150ms; + } .duration-200 { --tw-duration: 200ms; transition-duration: 200ms;