From e9aa78e5b2d930ea9fb0f11cf48392700a712891 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Sat, 23 May 2026 13:31:13 +0000 Subject: [PATCH 1/4] Fix paywall CTA navigation sequencing in restricted action Use dismissModal with afterTransition for web/native owner CTA to avoid modal-stack race, and add regression tests for web and native routes. --- .../index.native.tsx | 11 +- .../WorkspaceOwnerRestrictedAction/index.tsx | 11 +- .../WorkspaceOwnerRestrictedActionTest.tsx | 141 ++++++++++++++++++ 3 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 tests/unit/pages/RestrictedAction/WorkspaceOwnerRestrictedActionTest.tsx diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx index e2db15efe21e..a34803e0c07e 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; import Badge from '@components/Badge'; import Button from '@components/Button'; @@ -20,11 +20,12 @@ function WorkspaceOwnerRestrictedAction() { const styles = useThemeStyles(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Unlock']); - const activeRoute = useMemo(() => Navigation.getActiveRoute(), []); const goToSubscription = useCallback(() => { - Navigation.closeRHPFlow(); - Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION.getRoute(activeRoute)); - }, [activeRoute]); + const activeRoute = Navigation.getActiveRoute(); + Navigation.dismissModal({ + afterTransition: () => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION.getRoute(activeRoute)), + }); + }, []); return ( { - Navigation.closeRHPFlow(); - Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD); - }; + const addPaymentCard = useCallback(() => { + Navigation.dismissModal({ + afterTransition: () => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD), + }); + }, []); return ( ({ + dismissModal: jest.fn(), + navigate: jest.fn(), + getActiveRoute: jest.fn(() => 'r/123'), + goBack: jest.fn(), +})); + +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: jest.fn((key: string) => key)}))); + +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({ + Unlock: () => null, + })), + useMemoizedLazyIllustrations: jest.fn(() => ({ + LockClosedOrange: () => null, + })), +})); + +jest.mock('@components/ScreenWrapper', () => { + function MockScreenWrapper({children}: {children: React.ReactNode}) { + return children; + } + return MockScreenWrapper; +}); + +jest.mock('@components/HeaderWithBackButton', () => { + function MockHeaderWithBackButton() { + return null; + } + return MockHeaderWithBackButton; +}); + +jest.mock('@components/ScrollView', () => { + function MockScrollView({children}: {children: React.ReactNode}) { + return children; + } + return MockScrollView; +}); + +jest.mock('@components/Icon', () => { + function MockIcon() { + return null; + } + return MockIcon; +}); + +jest.mock('@components/Badge', () => { + function MockBadge() { + return null; + } + return MockBadge; +}); + +jest.mock('@components/Text', () => { + function MockText({children}: {children: React.ReactNode}) { + return children; + } + return MockText; +}); + +jest.mock('@components/Button', () => { + const {TouchableOpacity, Text} = jest.requireActual('react-native'); + function MockButton({text, onPress}: {text: string; onPress?: () => void}) { + return ( + + {text} + + ); + } + return MockButton; +}); + +type DismissModalOptions = { + afterTransition: () => void; +}; + +function getDismissModalOptions(): DismissModalOptions | undefined { + return jest.mocked(Navigation.dismissModal).mock.calls.at(0)?.at(0) as DismissModalOptions | undefined; +} + +describe('WorkspaceOwnerRestrictedAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('dismisses modal before navigating to add payment card on web', () => { + render(); + + fireEvent.press(screen.getByText('workspace.restrictedAction.addPaymentCard')); + + expect(Navigation.dismissModal).toHaveBeenCalledTimes(1); + const options = getDismissModalOptions(); + expect(typeof options?.afterTransition).toBe('function'); + + options?.afterTransition(); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD); + }); + + it('dismisses modal before navigating to subscription route on native', () => { + render(); + + fireEvent.press(screen.getByText('workspace.restrictedAction.goToSubscription')); + + expect(Navigation.dismissModal).toHaveBeenCalledTimes(1); + expect(Navigation.getActiveRoute).toHaveBeenCalledTimes(1); + + const options = getDismissModalOptions(); + expect(typeof options?.afterTransition).toBe('function'); + + options?.afterTransition(); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_SUBSCRIPTION.getRoute('r/123')); + }); +}); From d689db409dd62cff9358ebe9acf0c2a765f737df Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Sat, 23 May 2026 19:01:02 +0000 Subject: [PATCH 2/4] Fix paywall CTA navigation race while preserving RHP flow Use closeRHPFlow with waitForTransition navigation for web/native owner CTAs to avoid modal-stack race without dismissing the full modal stack, and add regression tests. --- .../index.native.tsx | 5 +-- .../WorkspaceOwnerRestrictedAction/index.tsx | 5 +-- .../WorkspaceOwnerRestrictedActionTest.tsx | 40 +++++-------------- 3 files changed, 15 insertions(+), 35 deletions(-) diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx index a34803e0c07e..326b0f9efcb9 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx @@ -22,9 +22,8 @@ function WorkspaceOwnerRestrictedAction() { const goToSubscription = useCallback(() => { const activeRoute = Navigation.getActiveRoute(); - Navigation.dismissModal({ - afterTransition: () => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION.getRoute(activeRoute)), - }); + Navigation.closeRHPFlow(); + Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION.getRoute(activeRoute), {waitForTransition: true}); }, []); return ( diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.tsx index 77a0e4c306b4..412e4a2bca0f 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.tsx @@ -21,9 +21,8 @@ function WorkspaceOwnerRestrictedAction() { const expensifyIcons = useMemoizedLazyExpensifyIcons(['Unlock']); const addPaymentCard = useCallback(() => { - Navigation.dismissModal({ - afterTransition: () => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD), - }); + Navigation.closeRHPFlow(); + Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD, {waitForTransition: true}); }, []); return ( diff --git a/tests/unit/pages/RestrictedAction/WorkspaceOwnerRestrictedActionTest.tsx b/tests/unit/pages/RestrictedAction/WorkspaceOwnerRestrictedActionTest.tsx index 0009506879bf..760b8960864a 100644 --- a/tests/unit/pages/RestrictedAction/WorkspaceOwnerRestrictedActionTest.tsx +++ b/tests/unit/pages/RestrictedAction/WorkspaceOwnerRestrictedActionTest.tsx @@ -5,12 +5,13 @@ import Navigation from '@libs/Navigation/Navigation'; import WorkspaceOwnerRestrictedActionNative from '@src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native'; import ROUTES from '@src/ROUTES'; -// Jest resolves index.native.tsx by default in the RN test environment; import web implementation explicitly. -// eslint-disable-next-line import/extensions -import WorkspaceOwnerRestrictedActionWeb from '@src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.tsx'; +// Jest resolves index.native.tsx by default in the RN test environment; load web implementation explicitly. +const {default: WorkspaceOwnerRestrictedActionWeb} = jest.requireActual<{default: React.ComponentType}>( + '@src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.tsx', +); jest.mock('@libs/Navigation/Navigation', () => ({ - dismissModal: jest.fn(), + closeRHPFlow: jest.fn(), navigate: jest.fn(), getActiveRoute: jest.fn(() => 'r/123'), goBack: jest.fn(), @@ -96,46 +97,27 @@ jest.mock('@components/Button', () => { return MockButton; }); -type DismissModalOptions = { - afterTransition: () => void; -}; - -function getDismissModalOptions(): DismissModalOptions | undefined { - return jest.mocked(Navigation.dismissModal).mock.calls.at(0)?.at(0) as DismissModalOptions | undefined; -} - describe('WorkspaceOwnerRestrictedAction', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('dismisses modal before navigating to add payment card on web', () => { + it('closes RHP flow and navigates to add payment card on web', () => { render(); fireEvent.press(screen.getByText('workspace.restrictedAction.addPaymentCard')); - expect(Navigation.dismissModal).toHaveBeenCalledTimes(1); - const options = getDismissModalOptions(); - expect(typeof options?.afterTransition).toBe('function'); - - options?.afterTransition(); - - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD); + expect(Navigation.closeRHPFlow).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD, {waitForTransition: true}); }); - it('dismisses modal before navigating to subscription route on native', () => { + it('closes RHP flow and navigates to subscription route on native', () => { render(); fireEvent.press(screen.getByText('workspace.restrictedAction.goToSubscription')); - expect(Navigation.dismissModal).toHaveBeenCalledTimes(1); + expect(Navigation.closeRHPFlow).toHaveBeenCalledTimes(1); expect(Navigation.getActiveRoute).toHaveBeenCalledTimes(1); - - const options = getDismissModalOptions(); - expect(typeof options?.afterTransition).toBe('function'); - - options?.afterTransition(); - - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_SUBSCRIPTION.getRoute('r/123')); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_SUBSCRIPTION.getRoute('r/123'), {waitForTransition: true}); }); }); From 95744b17890d62c90781c55beb215c38a8d90508 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Sat, 23 May 2026 19:18:30 +0000 Subject: [PATCH 3/4] Run prettier on WorkspaceOwnerRestrictedAction test --- .../RestrictedAction/WorkspaceOwnerRestrictedActionTest.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unit/pages/RestrictedAction/WorkspaceOwnerRestrictedActionTest.tsx b/tests/unit/pages/RestrictedAction/WorkspaceOwnerRestrictedActionTest.tsx index 760b8960864a..d7c4556e3e33 100644 --- a/tests/unit/pages/RestrictedAction/WorkspaceOwnerRestrictedActionTest.tsx +++ b/tests/unit/pages/RestrictedAction/WorkspaceOwnerRestrictedActionTest.tsx @@ -6,9 +6,7 @@ import WorkspaceOwnerRestrictedActionNative from '@src/pages/RestrictedAction/Wo import ROUTES from '@src/ROUTES'; // Jest resolves index.native.tsx by default in the RN test environment; load web implementation explicitly. -const {default: WorkspaceOwnerRestrictedActionWeb} = jest.requireActual<{default: React.ComponentType}>( - '@src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.tsx', -); +const {default: WorkspaceOwnerRestrictedActionWeb} = jest.requireActual<{default: React.ComponentType}>('@src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.tsx'); jest.mock('@libs/Navigation/Navigation', () => ({ closeRHPFlow: jest.fn(), From a69fe3fbdcb6210a7df8f52daf3d428aa28ebf28 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Tue, 26 May 2026 18:27:07 +0000 Subject: [PATCH 4/4] Remove unnecessary useCallback from paywall owner CTA handlers Drop manual memoization from the restricted action CTA handlers while keeping the existing closeRHPFlow plus waitForTransition navigation behavior unchanged. --- .../WorkspaceOwnerRestrictedAction/index.native.tsx | 6 +++--- .../Workspace/WorkspaceOwnerRestrictedAction/index.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx index 326b0f9efcb9..51020582ec8b 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction/index.native.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Badge from '@components/Badge'; import Button from '@components/Button'; @@ -20,11 +20,11 @@ function WorkspaceOwnerRestrictedAction() { const styles = useThemeStyles(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Unlock']); - const goToSubscription = useCallback(() => { + const goToSubscription = () => { const activeRoute = Navigation.getActiveRoute(); Navigation.closeRHPFlow(); Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION.getRoute(activeRoute), {waitForTransition: true}); - }, []); + }; return ( { + const addPaymentCard = () => { Navigation.closeRHPFlow(); Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD, {waitForTransition: true}); - }, []); + }; return (