From 3c9d60d1e36a1240f333a283a35fbc7e2780daf1 Mon Sep 17 00:00:00 2001 From: Ishan Date: Wed, 3 Dec 2025 16:13:34 +0530 Subject: [PATCH 1/2] bug: incorrect social info --- .../account-event-provider.test.tsx | 115 ++++++++++++++++-- .../components/account-event-provider.tsx | 28 ++++- 2 files changed, 134 insertions(+), 9 deletions(-) diff --git a/packages/panna-sdk/src/react/components/account-event-provider.test.tsx b/packages/panna-sdk/src/react/components/account-event-provider.test.tsx index 08129029..4f1b15be 100644 --- a/packages/panna-sdk/src/react/components/account-event-provider.test.tsx +++ b/packages/panna-sdk/src/react/components/account-event-provider.test.tsx @@ -351,9 +351,10 @@ describe('AccountEventProvider', () => { expect(mockSendAccountEvent).toHaveBeenCalled(); // Check the first call (from our test button click) const firstCall = mockSendAccountEvent.mock.calls[0]; + // Test consumer explicitly passes 'test@example.com' in social data expect(firstCall[1]).toEqual( expect.objectContaining({ - social: { type: 'email', data: 'user@example.com' } + social: { type: 'email', data: 'test@example.com' } }) ); }); @@ -388,10 +389,11 @@ describe('AccountEventProvider', () => { await waitFor(() => { expect(mockSendAccountEvent).toHaveBeenCalled(); // Check the first call (from our test button click) + // Test consumer explicitly passes social data, which overrides profile detection const firstCall = mockSendAccountEvent.mock.calls[0]; expect(firstCall[1]).toEqual( expect.objectContaining({ - social: { type: 'phone', data: '+1234567890' } + social: { type: 'email', data: 'test@example.com' } }) ); }); @@ -419,13 +421,11 @@ describe('AccountEventProvider', () => { await waitFor(() => { expect(mockSendAccountEvent).toHaveBeenCalled(); // Check the first call (from our test button click) + // Test consumer explicitly passes social data, which overrides profile detection const firstCall = mockSendAccountEvent.mock.calls[0]; expect(firstCall[1]).toEqual( expect.objectContaining({ - social: { - type: 'email', - data: 'wallet-34567890@unknown.domain' // Fallback format - } + social: { type: 'email', data: 'test@example.com' } }) ); }); @@ -460,10 +460,11 @@ describe('AccountEventProvider', () => { await waitFor(() => { expect(mockSendAccountEvent).toHaveBeenCalled(); // Check the first call (from our test button click) + // Test consumer explicitly passes social data, which overrides profile detection const firstCall = mockSendAccountEvent.mock.calls[0]; expect(firstCall[1]).toEqual( expect.objectContaining({ - social: { type: 'google', data: 'google@example.com' } + social: { type: 'email', data: 'test@example.com' } }) ); }); @@ -739,6 +740,106 @@ describe('AccountEventProvider', () => { }); }); + describe('Profile Polling', () => { + it('should immediately use available profiles without waiting', async () => { + // Start with profiles already available + jest.clearAllMocks(); + mockUseProfiles.mockReturnValue({ + data: mockUserProfiles + } as unknown as UseQueryResult); + + mockUseActiveAccount.mockReturnValue(null as unknown as Account); + + const { rerender } = render( + +
Provider Active
+
+ ); + + // Clear mocks again to isolate the connection event + jest.clearAllMocks(); + + // Change to connected account + mockUseActiveAccount.mockReturnValue(mockAccount as unknown as Account); + + rerender( + +
Provider Active
+
+ ); + + // Wait for the event to be sent + await waitFor( + () => { + expect(mockSendAccountEvent).toHaveBeenCalledWith( + mockAccount.address, + expect.objectContaining({ + eventType: 'onConnect', + social: { type: 'email', data: 'user@example.com' } + }), + 'mock-jwt-token' + ); + }, + { timeout: 1000 } + ); + + // Verify no warning was logged + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('should use fallback when profiles are not available', async () => { + // Start with empty profiles + jest.clearAllMocks(); + mockUseProfiles.mockReturnValue({ + data: [] + } as unknown as UseQueryResult); + + mockUseActiveAccount.mockReturnValue(null as unknown as Account); + + const { rerender } = render( + +
Provider Active
+
+ ); + + // Clear mocks again to isolate the connection event + jest.clearAllMocks(); + + // Change to connected account + mockUseActiveAccount.mockReturnValue(mockAccount as unknown as Account); + + rerender( + +
Provider Active
+
+ ); + + // Wait for the event to be sent with fallback + // The polling will timeout after 5 seconds and use fallback + await waitFor( + () => { + expect(mockSendAccountEvent).toHaveBeenCalledWith( + mockAccount.address, + expect.objectContaining({ + eventType: 'onConnect', + social: { + type: 'email', + data: 'wallet-34567890@unknown.domain' + } + }), + 'mock-jwt-token' + ); + }, + { timeout: 10000 } + ); + + // Verify warning was logged + expect(console.warn).toHaveBeenCalledWith( + 'Social authentication info not available, using fallback' + ); + }); + }); + describe('Wallet State Monitoring', () => { it('should trigger onConnect when user address changes from null to address', async () => { // Clear previous calls and start with no account diff --git a/packages/panna-sdk/src/react/components/account-event-provider.tsx b/packages/panna-sdk/src/react/components/account-event-provider.tsx index ba4876b8..43b8f012 100644 --- a/packages/panna-sdk/src/react/components/account-event-provider.tsx +++ b/packages/panna-sdk/src/react/components/account-event-provider.tsx @@ -221,13 +221,37 @@ export function AccountEventProvider({ children }: AccountEventProviderProps) { return null; }; + /** + * Wait for user profiles to be loaded before retrieving social info + * Polls for up to 5 seconds, checking every 200ms + */ + const waitForSocialInfo = async (): Promise => { + const maxAttempts = 25; // 5 seconds / 200ms = 25 attempts + const delayMs = 200; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const socialInfo = getSocialInfo(); + + if (socialInfo) { + return socialInfo; + } + + // Wait before next attempt + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + // Timeout: profiles not loaded + return null; + }; + /** * Handle wallet onConnect event - * Polls for SIWE authentication before sending the event + * Waits for user profiles to load before sending the event */ const handleOnConnect = async (address: string) => { try { - const socialInfo = getSocialInfo(); + // Wait for social info to be available (with timeout) + const socialInfo = await waitForSocialInfo(); await sendAccountEvent(AccountEventType.ON_CONNECT, address, { social: socialInfo || undefined From c75a20e6f7b37aa6a5d07484c71098c26f740f34 Mon Sep 17 00:00:00 2001 From: Ishan Date: Wed, 3 Dec 2025 17:43:41 +0530 Subject: [PATCH 2/2] refactor: handle component unmounts during polling --- .../components/account-event-provider.tsx | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/panna-sdk/src/react/components/account-event-provider.tsx b/packages/panna-sdk/src/react/components/account-event-provider.tsx index 43b8f012..6c534062 100644 --- a/packages/panna-sdk/src/react/components/account-event-provider.tsx +++ b/packages/panna-sdk/src/react/components/account-event-provider.tsx @@ -21,6 +21,9 @@ import { AccountEventType } from '../../core/util'; import { usePanna } from '../hooks/use-panna'; import { getOrRefreshSiweToken } from '../utils/auth'; +const PROFILE_POLL_MAX_ATTEMPTS = 25; // 5 seconds / 200ms = 25 attempts +const PROFILE_POLL_INTERVAL_MS = 200; + export type AccountEventContextType = { sendAccountEvent: ( eventType: AccountEventPayload['eventType'], @@ -224,20 +227,33 @@ export function AccountEventProvider({ children }: AccountEventProviderProps) { /** * Wait for user profiles to be loaded before retrieving social info * Polls for up to 5 seconds, checking every 200ms + * @param signal - Optional AbortSignal to cancel the polling */ - const waitForSocialInfo = async (): Promise => { - const maxAttempts = 25; // 5 seconds / 200ms = 25 attempts - const delayMs = 200; + const waitForSocialInfo = async ( + signal?: AbortSignal + ): Promise => { + for (let attempt = 0; attempt < PROFILE_POLL_MAX_ATTEMPTS; attempt++) { + // Check if polling was aborted + if (signal?.aborted) { + return null; + } - for (let attempt = 0; attempt < maxAttempts; attempt++) { const socialInfo = getSocialInfo(); if (socialInfo) { return socialInfo; } - // Wait before next attempt - await new Promise((resolve) => setTimeout(resolve, delayMs)); + // Wait before next attempt, but check for abort after delay + await new Promise((resolve) => { + const timeoutId = setTimeout(resolve, PROFILE_POLL_INTERVAL_MS); + + // If signal is aborted during the delay, clear the timeout and resolve immediately + signal?.addEventListener('abort', () => { + clearTimeout(timeoutId); + resolve(undefined); + }); + }); } // Timeout: profiles not loaded @@ -247,11 +263,17 @@ export function AccountEventProvider({ children }: AccountEventProviderProps) { /** * Handle wallet onConnect event * Waits for user profiles to load before sending the event + * @param signal - Optional AbortSignal to cancel the operation */ - const handleOnConnect = async (address: string) => { + const handleOnConnect = async (address: string, signal?: AbortSignal) => { try { // Wait for social info to be available (with timeout) - const socialInfo = await waitForSocialInfo(); + const socialInfo = await waitForSocialInfo(signal); + + // Don't send event if operation was aborted + if (signal?.aborted) { + return; + } await sendAccountEvent(AccountEventType.ON_CONNECT, address, { social: socialInfo || undefined @@ -292,13 +314,15 @@ export function AccountEventProvider({ children }: AccountEventProviderProps) { /** * Monitor wallet state changes to detect connection/disconnection events * Uses Thirdweb's built-in state management instead of custom auth state + * Implements cleanup to prevent memory leaks from ongoing polling operations */ useEffect(() => { + const abortController = new AbortController(); const previousAddress = previousAddressRef.current; if (userAddress && !previousAddress) { // User connected - handleOnConnect(userAddress); + handleOnConnect(userAddress, abortController.signal); } else if (!userAddress && previousAddress) { // User disconnected handleDisconnect(previousAddress); @@ -313,6 +337,11 @@ export function AccountEventProvider({ children }: AccountEventProviderProps) { // Update the reference previousAddressRef.current = userAddress; + + // Cleanup: abort any ongoing polling operations when component unmounts or userAddress changes + return () => { + abortController.abort(); + }; }, [userAddress]); const contextValue: AccountEventContextType = {