From 3ba1d33fbe639c7c7b2b225e5bde131708663c37 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Wed, 11 Feb 2026 15:35:14 -0800 Subject: [PATCH] chore: agent points hook feedback (#1727) * agent points feedback * agent feeddback --- app/src/screens/home/PointsInfoScreen.tsx | 33 +- .../screens/home/PointsInfoScreen.test.tsx | 339 ++++++++++++++++++ 2 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 app/tests/src/screens/home/PointsInfoScreen.test.tsx diff --git a/app/src/screens/home/PointsInfoScreen.tsx b/app/src/screens/home/PointsInfoScreen.tsx index f5d913ac7..cb3b13a44 100644 --- a/app/src/screens/home/PointsInfoScreen.tsx +++ b/app/src/screens/home/PointsInfoScreen.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import React from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { Image, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ScrollView, Text, View, XStack, YStack } from 'tamagui'; @@ -22,7 +22,10 @@ import CloudBackupIcon from '@/assets/icons/cloud_backup.svg'; import PushNotificationsIcon from '@/assets/icons/push_notifications.svg'; import StarIcon from '@/assets/icons/star.svg'; import Referral from '@/assets/images/referral.png'; -import { getModalCallbacks } from '@/utils/modalCallbackRegistry'; +import { + getModalCallbacks, + unregisterModalCallbacks, +} from '@/utils/modalCallbackRegistry'; type PointsInfoScreenProps = StaticScreenProps< | { @@ -94,6 +97,30 @@ const PointsInfoScreen: React.FC = ({ const { showNextButton, callbackId } = params || {}; const { left, right, bottom } = useSafeAreaInsets(); const callbacks = callbackId ? getModalCallbacks(callbackId) : undefined; + const buttonPressedRef = useRef(false); + + // Handle button press: mark as pressed and call the callback + const handleNextPress = useCallback(() => { + if (callbackId !== undefined) { + buttonPressedRef.current = true; + } + callbacks?.onButtonPress(); + }, [callbackId, callbacks]); + + // Cleanup: Call onModalDismiss and unregister callbacks when component unmounts + // Only call onModalDismiss if user navigated back (didn't press the button) + useEffect(() => { + return () => { + if (callbackId !== undefined) { + // Always unregister on unmount to prevent memory leaks + if (!buttonPressedRef.current) { + // User navigated back without pressing "Next" - call onModalDismiss to clear referrer + callbacks?.onModalDismiss(); + } + unregisterModalCallbacks(callbackId); + } + }; + }, [callbackId, callbacks]); return ( @@ -140,7 +167,7 @@ const PointsInfoScreen: React.FC = ({ {showNextButton && ( - Next + Next )} diff --git a/app/tests/src/screens/home/PointsInfoScreen.test.tsx b/app/tests/src/screens/home/PointsInfoScreen.test.tsx new file mode 100644 index 000000000..698170f2d --- /dev/null +++ b/app/tests/src/screens/home/PointsInfoScreen.test.tsx @@ -0,0 +1,339 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { act, render } from '@testing-library/react-native'; + +import PointsInfoScreen from '@/screens/home/PointsInfoScreen'; +import { unregisterModalCallbacks } from '@/utils/modalCallbackRegistry'; + +jest.mock('react-native', () => { + const MockView = ({ children, ...props }: any) => ( + {children} + ); + const MockText = ({ children, ...props }: any) => ( + {children} + ); + const MockImage = ({ ...props }: any) => ; + + return { + __esModule: true, + Image: MockImage, + Platform: { OS: 'ios', select: jest.fn() }, + StyleSheet: { + create: (styles: any) => styles, + flatten: (style: any) => style, + }, + Text: MockText, + View: MockView, + }; +}); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: jest.fn(() => ({ + top: 0, + bottom: 0, + left: 0, + right: 0, + })), +})); + +// Mock Tamagui components +jest.mock('tamagui', () => { + const View: any = 'View'; + const Text: any = 'Text'; + const createViewComponent = (displayName: string) => { + const MockComponent = ({ children, ...props }: any) => ( + + {children} + + ); + MockComponent.displayName = displayName; + return MockComponent; + }; + + const MockYStack = createViewComponent('YStack'); + const MockXStack = createViewComponent('XStack'); + const MockView = createViewComponent('View'); + const MockScrollView = createViewComponent('ScrollView'); + + const MockText = ({ children, ...props }: any) => ( + {children} + ); + MockText.displayName = 'Text'; + + return { + __esModule: true, + YStack: MockYStack, + XStack: MockXStack, + View: MockView, + Text: MockText, + ScrollView: MockScrollView, + }; +}); + +// Mock mobile SDK components +jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({ + PrimaryButton: ({ children, onPress, ...props }: any) => ( + + {children} + + ), + Title: ({ children }: any) =>
{children}
, +})); + +// Mock SVG icons +jest.mock('@/assets/icons/checkmark_square.svg', () => 'CheckmarkSquareIcon'); +jest.mock('@/assets/icons/cloud_backup.svg', () => 'CloudBackupIcon'); +jest.mock( + '@/assets/icons/push_notifications.svg', + () => 'PushNotificationsIcon', +); +jest.mock('@/assets/icons/star.svg', () => 'StarIcon'); + +// Mock images +jest.mock('@/assets/images/referral.png', () => 'ReferralImage'); + +jest.mock('@/utils/modalCallbackRegistry', () => ({ + getModalCallbacks: jest.fn(), + registerModalCallbacks: jest.fn(), + unregisterModalCallbacks: jest.fn(), +})); + +const mockUnregisterModalCallbacks = + unregisterModalCallbacks as jest.MockedFunction< + typeof unregisterModalCallbacks + >; + +// Mock getModalCallbacks at module level +const { getModalCallbacks } = jest.requireMock('@/utils/modalCallbackRegistry'); + +describe('PointsInfoScreen', () => { + const mockOnButtonPress = jest.fn(); + const mockOnModalDismiss = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup getModalCallbacks to return our mock callbacks + getModalCallbacks.mockImplementation((id: number) => { + if (id === 1) { + return { + onButtonPress: mockOnButtonPress, + onModalDismiss: mockOnModalDismiss, + }; + } + return undefined; + }); + }); + + it('should render without crashing', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('should not show Next button when showNextButton is false', () => { + const { queryByTestId } = render( + , + ); + + // Verify button is not rendered + const nextButton = queryByTestId('primary-button'); + expect(nextButton).toBeNull(); + }); + + it('should show Next button when showNextButton is true', () => { + const { getByTestId } = render( + , + ); + + // Verify button is rendered + const nextButton = getByTestId('primary-button'); + expect(nextButton).toBeTruthy(); + }); + + describe('Callback handling', () => { + it('should call onModalDismiss and unregister callbacks when component unmounts without button press', () => { + const { unmount } = render( + , + ); + + // Initially, no callbacks should be called + expect(mockOnModalDismiss).not.toHaveBeenCalled(); + expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled(); + expect(mockOnButtonPress).not.toHaveBeenCalled(); + + // Unmount the component (simulating user navigating back) + act(() => { + unmount(); + }); + + // onModalDismiss should be called to clear referrer + expect(mockOnModalDismiss).toHaveBeenCalledTimes(1); + // Callbacks should be unregistered to prevent memory leak + expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1); + // onButtonPress should not be called (user didn't press the button) + expect(mockOnButtonPress).not.toHaveBeenCalled(); + }); + + it('should call onModalDismiss on unmount even when showNextButton is false', () => { + const { unmount } = render( + , + ); + + act(() => { + unmount(); + }); + + // Callbacks should be called even if button is not shown (callbackId is present) + expect(mockOnModalDismiss).toHaveBeenCalledTimes(1); + expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1); + }); + + it('should handle missing callbacks gracefully', () => { + // Mock getModalCallbacks to return undefined + getModalCallbacks.mockReturnValue(undefined); + + const { unmount } = render( + , + ); + + // Should not throw when unmounting with missing callbacks + expect(() => { + act(() => { + unmount(); + }); + }).not.toThrow(); + + // Should still attempt to unregister + expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(999); + }); + + it('should handle missing callbackId gracefully', () => { + const { unmount } = render( + , + ); + + // Should not throw when unmounting without callbackId + expect(() => { + act(() => { + unmount(); + }); + }).not.toThrow(); + + // Should not attempt to unregister if no callbackId + expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled(); + }); + }); + + describe('Button press handling', () => { + it('should call onButtonPress and unregister callbacks when Next button is pressed, then not call onModalDismiss on unmount', () => { + const { getByTestId, unmount } = render( + , + ); + + const primaryButton = getByTestId('primary-button'); + + // Press the button + act(() => { + primaryButton.props.onPress(); + }); + + // onButtonPress should be called + expect(mockOnButtonPress).toHaveBeenCalledTimes(1); + // Callbacks should NOT be unregistered yet (component still mounted) + expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled(); + // onModalDismiss should NOT be called (button was pressed) + expect(mockOnModalDismiss).not.toHaveBeenCalled(); + + // Clear mock calls from button press + jest.clearAllMocks(); + + // Unmount the component + act(() => { + unmount(); + }); + + // onModalDismiss should NOT be called (button was pressed, not navigated back) + expect(mockOnModalDismiss).not.toHaveBeenCalled(); + // Callbacks should be unregistered to prevent memory leak + expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1); + }); + + it('should allow multiple button presses without unregistering callbacks (regression test)', () => { + const { getByTestId } = render( + , + ); + + const primaryButton = getByTestId('primary-button'); + + // Press the button first time + act(() => { + primaryButton.props.onPress(); + }); + + expect(mockOnButtonPress).toHaveBeenCalledTimes(1); + expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled(); + + // Press the button again (simulating returning to this screen after modal dismissal) + act(() => { + primaryButton.props.onPress(); + }); + + // onButtonPress should be called again + expect(mockOnButtonPress).toHaveBeenCalledTimes(2); + // Callbacks should still NOT be unregistered (component still mounted) + expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled(); + }); + }); + + describe('Referrer cleanup integration', () => { + it('should ensure cleanup is called in correct order for referrer clearing', () => { + const callOrder: string[] = []; + + const onModalDismissWithTracking = jest.fn(() => { + callOrder.push('onModalDismiss'); + }); + + const unregisterWithTracking = jest.fn(() => { + callOrder.push('unregister'); + }); + + getModalCallbacks.mockReturnValue({ + onButtonPress: mockOnButtonPress, + onModalDismiss: onModalDismissWithTracking, + }); + + mockUnregisterModalCallbacks.mockImplementation(unregisterWithTracking); + + const { unmount } = render( + , + ); + + act(() => { + unmount(); + }); + + // Verify onModalDismiss is called before unregister + expect(callOrder).toEqual(['onModalDismiss', 'unregister']); + }); + }); +});