Moved auth only providers to AuthScreen#86441
Conversation
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
|
@thesahindia @Julesssss One of you needs to copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ffc6d60752
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
@adhorodyski Can I move this component inside ComposeProviders? To fix https://github.com/Expensify/App/pull/86441/changes#r3004502757 and https://github.com/Expensify/App/pull/86441/changes#r3004502759
Asking you since you authored #84052
There was a problem hiding this comment.
KeyboardShortcutsHandler? I believe so, I don't see why not.
|
PR doesn’t need product input as a refactor PR. Unassigning and unsubscribing myself. |
|
Here's the full analysis of all Providers in Providers in AuthScreens (19 total)Moved from
|
| # | Provider | Purpose | Consumers |
|---|---|---|---|
| 1 | AttachmentModalContextProvider |
Manages attachment visibility/state across report screens | Attachment modal screens |
| 2 | PlaybackContextProvider |
Central video playback state, cross-report video sharing | Video player components |
| 3 | VolumeContextProvider |
Shared video volume state with persistence | Video player controls |
| 4 | VideoPopoverMenuContextProvider |
Playback speed settings, download options | Video popover menu |
| 5 | FullScreenContextProvider |
Fullscreen state and layout locking for video | Video fullscreen mode |
| 6 | SearchRouterContextProvider |
Global search modal visibility, keyboard shortcuts | Search router, command palette |
| 7 | ProductTrainingContextProvider |
Priority-based onboarding tooltips | Product training tooltips |
| 8 | ExpensifyCardContextProvider |
Revealed card details, loading/error states | Wallet/card settings pages |
| 9 | TravelCVVContextProvider |
Travel card CVV (in-memory only, never persisted) | Travel CVV page |
| 10 | KYCWallContextProvider |
Ref-based access to KYC verification modal | KYC wall trigger components |
| 11 | WideRHPContextProvider |
Wide/Super-Wide Right Hand Panel display, sizing, animations | Right modal navigator, receipt pane |
| 12 | KeyboardDismissibleFlatListContextProvider |
Keyboard-aware list scrolling (Reanimated shared values) | Chat list, report action list |
Already in AuthScreens on main (7 providers)
| # | Provider | Purpose | Consumers |
|---|---|---|---|
| 13 | CurrencyListContextProvider |
Currency symbols and decimal formatting (from Onyx) | Amount inputs, expense displays |
| 14 | OptionsListContextProvider |
Computed list of selectable reports/contacts for autocomplete | Participant/recipient selection |
| 15 | SidebarOrderedReportsContextProvider |
Sorted/filtered report list for LHN with caching | Left Hand Navigation sidebar |
| 16 | SearchContextProvider |
Search state, results, selected transactions | Search results page |
| 17 | LockedAccountModalProvider |
Locked account modal display | Account lock warning |
| 18 | DelegateNoAccessModalProvider |
Modal for restricted delegate actions | Delegate access checks |
| 19 | SupportalPermissionDeniedModalProvider |
Modal for Supportal permission denied states | Supportal permission checks |
Provider Dependency Groups
- Video cluster (2→5):
PlaybackContext→Volume,VideoPopoverMenu,FullScreen— tightly coupled, order matters - Search cluster (6, 14, 16):
SearchRouterContext+OptionsListContext+SearchContext— work together for search flows - Wallet cluster (8, 9, 10):
ExpensifyCard+TravelCVV+KYCWall— all wallet/payment-related - Modal cluster (17, 18, 19):
LockedAccount+DelegateNoAccess+SupportalPermissionDenied— UI state for restriction modals
Key Observation
All 19 providers are exclusively consumed by authenticated screens — none are needed for the unauthenticated/login flow. Moving the 12 providers from App.tsx into AuthScreens is a clean scoping improvement: these contexts won't be initialized until the user is actually authenticated, avoiding unnecessary provider setup during the login flow.
|
@MelvinBot Can you find if we can move down any of these providers? |
|
I traced all consumers of each of the 19 providers in AuthScreens to determine which can be moved to a narrower scope. Here's the summary: Providers That CAN Be Moved Down
Details on each:1. 2. 3. 4. function SupportalPermissionDeniedModalProvider({children}) {
const [payload] = useOnyx(ONYXKEYS.SUPPORTAL_PERMISSION_DENIED);
return <>{children}<ConfirmModal isVisible={!!payload} .../></>;
}This doesn't need to be a provider at all — it could be rendered as a standalone sibling component, eliminating one nesting level. Providers That CANNOT Be Moved Down
TL;DR: 4 of 19 providers can be moved down or refactored: |
| showSupportalPermissionDeniedModal(supportalPermissionDeniedPayload.command); | ||
| }, [supportalPermissionDeniedPayload]); | ||
|
|
||
| return null; |
There was a problem hiding this comment.
A component that only ever returns null is a code smell. At that point, it should just be a hook. But in this case, it's only used in one place, so I'm not sure it really should be a custom hook vs just inlined in the only component it's used in. I don't feel strongly if you want to keep it encapsulated in a custom hook for organizational purposes.
There was a problem hiding this comment.
@roryabraham I think @adhorodyski can comment on this better, as he did the original change, and we are using this pattern at many places in the codebase.
There was a problem hiding this comment.
Sure, we can see what @adhorodyski has to say about it
There was a problem hiding this comment.
Please look into Expensify.tsx or DelegateAccessHandler.tsx (and others) as examples - I wouldn't say it's a direct code smell, because JSX nodes often provide a genuinely better separation than hooks. The official docs state it clearly as a valid JSX return for the old render method, we're just more used to seeing it as an early return rather than the final state.
An additional JSX node is cheap to reconcile, separates itself from the parent when previewed with a profiler and can be rendered optionally which is something we cannot do with hooks. In my view it just helps organize the behavior with JSX in a declarative way (but we have to remember to not overuse it).
I cannot find the exact PRs of mine but had numerous discussions about this already and the truth is it all depends on what we're trying to achieve.
Looking at this specific case, we want to open a modal on an Onyx value change, which is a weird mix. I understand it was previously implemented this way, but is there no event that we can use to fire it based on this action? Both context provider and a renderless component/hook seem wrong to me here.
There was a problem hiding this comment.
Historically I found it useful to be able to declare a renderless <SynchronizeSystemX /> component that separates some API conversations, without a hook. I hate to say it, but it all depends - in your position I'd look into when we actually want to open up a modal instead of rewriting X to Z for the sake of it.
There was a problem hiding this comment.
An additional JSX node is cheap to reconcile, separates itself from the parent when previewed with a profiler and can be rendered optionally which is something we cannot do with hooks
Interesting points.
|
🚧 @roryabraham has triggered a test Expensify/App build. You can view the workflow run here. |
This comment has been minimized.
This comment has been minimized.
Reviewer Checklist
Screenshots/VideosScreen.Recording.2026-04-15.at.11.28.10.mp4 |
|
@shubham1206agra Could you please add video to both mWeb and the native app? |
@shubham1206agra Could you add test steps to verify which features are impacted by moving the provider from App to AuthScreen? We should cover key cases to ensure the provider works correctly and prevent any component rendering issues outside it. |
|
@MelvinBot Can you write a test case for each provider? KeyboardDismissibleFlatListContextProvider |
@huult The web recording is sufficient in this case. You can check if you want |
|
Here are test cases for each of the 13 providers/components listed. Since I'm not an assignee on this PR, I can't push directly — but here are the complete tests you can add.
tests/ui/components/KeyboardDismissibleFlatListContextProviderTest.tsximport {renderHook} from '@testing-library/react-native';
import Onyx from 'react-native-onyx';
import {
KeyboardDismissibleFlatListContextProvider,
useKeyboardDismissibleFlatListState,
useKeyboardDismissibleFlatListActions,
} from '@components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext';
import ONYXKEYS from '@src/ONYXKEYS';
import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct';
import wrapOnyxWithWaitForBatchedUpdates from '../../utils/wrapOnyxWithWaitForBatchedUpdates';
const wrapper = ({children}: {children: React.ReactNode}) => (
<KeyboardDismissibleFlatListContextProvider>{children}</KeyboardDismissibleFlatListContextProvider>
);
describe('KeyboardDismissibleFlatListContextProvider', () => {
beforeAll(() => {
Onyx.init({keys: ONYXKEYS});
return waitForBatchedUpdatesWithAct();
});
beforeEach(() => {
wrapOnyxWithWaitForBatchedUpdates(Onyx);
});
afterEach(async () => {
await Onyx.clear();
await waitForBatchedUpdatesWithAct();
});
it('should provide state context with shared values', () => {
const {result} = renderHook(() => useKeyboardDismissibleFlatListState(), {wrapper});
expect(result.current.keyboardHeight).toBeDefined();
expect(result.current.keyboardOffset).toBeDefined();
expect(result.current.scrollY).toBeDefined();
expect(result.current.contentSizeHeight).toBeDefined();
expect(result.current.layoutMeasurementHeight).toBeDefined();
});
it('should provide actions context with onScroll and setListBehavior', () => {
const {result} = renderHook(() => useKeyboardDismissibleFlatListActions(), {wrapper});
expect(result.current.onScroll).toBeDefined();
expect(typeof result.current.setListBehavior).toBe('function');
});
});tests/ui/components/KYCWallContextProviderTest.tsximport {renderHook} from '@testing-library/react-native';
import {useContext} from 'react';
import KYCWallContextProvider, {KYCWallContext} from '@components/KYCWall/KYCWallContext';
const wrapper = ({children}: {children: React.ReactNode}) => (
<KYCWallContextProvider>{children}</KYCWallContextProvider>
);
describe('KYCWallContextProvider', () => {
it('should provide a ref object', () => {
const {result} = renderHook(() => useContext(KYCWallContext), {wrapper});
expect(result.current).toHaveProperty('current');
expect(result.current.current).toBeNull();
});
it('should provide same ref instance across re-renders', () => {
const {result, rerender} = renderHook(() => useContext(KYCWallContext), {wrapper});
const firstRef = result.current;
rerender({});
expect(result.current).toBe(firstRef);
});
});tests/ui/components/ProductTrainingContextProviderTest.tsx (already exists)This provider already has comprehensive tests at tests/ui/components/SearchRouterContextProviderTest.tsximport {renderHook, act} from '@testing-library/react-native';
import {SearchRouterContextProvider, useSearchRouterState, useSearchRouterActions} from '@components/Search/SearchRouter/SearchRouterContext';
jest.mock('@userActions/Modal', () => ({
close: (callback: () => void) => callback(),
}));
jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => ({
__esModule: true,
default: jest.fn(() => false),
}));
jest.mock('@libs/Navigation/Navigation', () => ({
navigationRef: {
getRootState: jest.fn(() => ({routes: []})),
},
}));
jest.mock('@libs/telemetry/activeSpans', () => ({
startSpan: jest.fn(),
endSpan: jest.fn(),
cancelSpan: jest.fn(),
getSpan: jest.fn(),
}));
const wrapper = ({children}: {children: React.ReactNode}) => (
<SearchRouterContextProvider>{children}</SearchRouterContextProvider>
);
describe('SearchRouterContextProvider', () => {
it('should initialize with search router not displayed', () => {
const {result} = renderHook(() => useSearchRouterState(), {wrapper});
expect(result.current.isSearchRouterDisplayed).toBe(false);
});
it('should provide all action methods', () => {
const {result} = renderHook(() => useSearchRouterActions(), {wrapper});
expect(typeof result.current.openSearchRouter).toBe('function');
expect(typeof result.current.closeSearchRouter).toBe('function');
expect(typeof result.current.toggleSearch).toBe('function');
expect(typeof result.current.registerSearchPageInput).toBe('function');
expect(typeof result.current.unregisterSearchPageInput).toBe('function');
});
it('should open and close search router', () => {
const {result} = renderHook(
() => ({
state: useSearchRouterState(),
actions: useSearchRouterActions(),
}),
{wrapper},
);
expect(result.current.state.isSearchRouterDisplayed).toBe(false);
act(() => {
result.current.actions.openSearchRouter();
});
expect(result.current.state.isSearchRouterDisplayed).toBe(true);
act(() => {
result.current.actions.closeSearchRouter();
});
expect(result.current.state.isSearchRouterDisplayed).toBe(false);
});
});tests/ui/components/SupportalPermissionDeniedModalTest.tsximport {render} from '@testing-library/react-native';
import Onyx from 'react-native-onyx';
import SupportalPermissionDeniedModal from '@components/SupportalPermissionDeniedModal';
import ONYXKEYS from '@src/ONYXKEYS';
import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct';
import wrapOnyxWithWaitForBatchedUpdates from '../../utils/wrapOnyxWithWaitForBatchedUpdates';
const mockShowConfirmModal = jest.fn(() => Promise.resolve());
jest.mock('@hooks/useConfirmModal', () => ({
__esModule: true,
default: () => ({showConfirmModal: mockShowConfirmModal}),
}));
jest.mock('@userActions/App', () => ({
clearSupportalPermissionDenied: jest.fn(),
}));
describe('SupportalPermissionDeniedModal', () => {
beforeAll(() => {
Onyx.init({keys: ONYXKEYS});
return waitForBatchedUpdatesWithAct();
});
beforeEach(() => {
wrapOnyxWithWaitForBatchedUpdates(Onyx);
mockShowConfirmModal.mockClear();
});
afterEach(async () => {
await Onyx.clear();
await waitForBatchedUpdatesWithAct();
});
it('should render null', () => {
const {toJSON} = render(<SupportalPermissionDeniedModal />);
expect(toJSON()).toBeNull();
});
it('should show confirm modal when supportal permission denied payload is set', async () => {
render(<SupportalPermissionDeniedModal />);
Onyx.merge(ONYXKEYS.SUPPORTAL_PERMISSION_DENIED, {command: 'TestCommand'});
await waitForBatchedUpdatesWithAct();
expect(mockShowConfirmModal).toHaveBeenCalledWith(
expect.objectContaining({
shouldShowCancelButton: false,
}),
);
});
it('should not show modal when payload is not set', () => {
render(<SupportalPermissionDeniedModal />);
expect(mockShowConfirmModal).not.toHaveBeenCalled();
});
});tests/ui/components/FullScreenContextProviderTest.tsximport {renderHook, act} from '@testing-library/react-native';
import FullScreenContextProvider, {useFullScreenState, useFullScreenActions} from '@components/VideoPlayerContexts/FullScreenContextProvider';
const wrapper = ({children}: {children: React.ReactNode}) => (
<FullScreenContextProvider>{children}</FullScreenContextProvider>
);
describe('FullScreenContextProvider', () => {
it('should provide state with refs', () => {
const {result} = renderHook(() => useFullScreenState(), {wrapper});
expect(result.current.isFullScreenRef).toBeDefined();
expect(result.current.isFullScreenRef.current).toBe(false);
expect(result.current.lockedWindowDimensionsRef).toBeDefined();
expect(result.current.lockedWindowDimensionsRef.current).toBeNull();
});
it('should lock and unlock window dimensions', () => {
const {result} = renderHook(
() => ({
state: useFullScreenState(),
actions: useFullScreenActions(),
}),
{wrapper},
);
const mockDimensions = {
shouldUseNarrowLayout: false,
isSmallScreenWidth: false,
isInNarrowPaneModal: false,
isExtraSmallScreenHeight: false,
isMediumScreenWidth: false,
isLargeScreenWidth: true,
isExtraSmallScreenWidth: false,
isSmallScreen: false,
onboardingIsMediumOrLargerScreenWidth: true,
isExtraLargeScreenWidth: false,
isInLandscapeMode: false,
};
act(() => {
result.current.actions.lockWindowDimensions(mockDimensions);
});
expect(result.current.state.lockedWindowDimensionsRef.current).toEqual(mockDimensions);
act(() => {
result.current.actions.unlockWindowDimensions();
});
expect(result.current.state.lockedWindowDimensionsRef.current).toBeNull();
});
it('should throw when useFullScreenState is used outside provider', () => {
expect(() => {
renderHook(() => useFullScreenState());
}).toThrow('useFullScreenState must be used within a FullScreenContextProvider');
});
it('should throw when useFullScreenActions is used outside provider', () => {
expect(() => {
renderHook(() => useFullScreenActions());
}).toThrow('useFullScreenActions must be used within a FullScreenContextProvider');
});
});tests/ui/components/PlaybackContextProviderTest.tsximport {renderHook} from '@testing-library/react-native';
import {PlaybackContextProvider, usePlaybackStateContext, usePlaybackActionsContext} from '@components/VideoPlayerContexts/PlaybackContext';
jest.mock('@libs/ReportUtils', () => ({
getReportOrDraftReport: jest.fn(),
isChatThread: jest.fn(() => false),
}));
jest.mock('@navigation/Navigation', () => ({
isNavigationReady: jest.fn(() => Promise.resolve()),
}));
const wrapper = ({children}: {children: React.ReactNode}) => (
<PlaybackContextProvider>{children}</PlaybackContextProvider>
);
describe('PlaybackContextProvider', () => {
it('should provide initial state values', () => {
const {result} = renderHook(() => usePlaybackStateContext(), {wrapper});
expect(result.current.currentlyPlayingURL).toBeNull();
expect(result.current.originalParent).toBeNull();
expect(result.current.sharedElement).toBeNull();
expect(result.current.currentVideoPlayerRef).toBeDefined();
expect(result.current.currentVideoViewRef).toBeDefined();
expect(result.current.mountedVideoPlayersRef).toBeDefined();
expect(result.current.playerStatus).toBeDefined();
expect(result.current.shareVersion).toBe(0);
});
it('should provide all action methods', () => {
const {result} = renderHook(() => usePlaybackActionsContext(), {wrapper});
expect(typeof result.current.updateCurrentURLAndReportID).toBe('function');
expect(typeof result.current.shareVideoPlayerElements).toBe('function');
expect(typeof result.current.setCurrentlyPlayingURL).toBe('function');
expect(typeof result.current.playVideo).toBe('function');
expect(typeof result.current.pauseVideo).toBe('function');
expect(typeof result.current.replayVideo).toBe('function');
expect(typeof result.current.stopVideo).toBe('function');
expect(typeof result.current.checkIfVideoIsPlaying).toBe('function');
expect(typeof result.current.resetVideoPlayerData).toBe('function');
expect(typeof result.current.updatePlayerStatus).toBe('function');
expect(typeof result.current.requestDonorReRegistration).toBe('function');
});
it('should throw when usePlaybackStateContext is used outside provider', () => {
expect(() => {
renderHook(() => usePlaybackStateContext());
}).toThrow('usePlaybackStateContext must be used within a PlaybackContextProvider');
});
it('should throw when usePlaybackActionsContext is used outside provider', () => {
expect(() => {
renderHook(() => usePlaybackActionsContext());
}).toThrow('usePlaybackActionsContext must be used within a PlaybackContextProvider');
});
});tests/ui/components/VideoPopoverMenuContextProviderTest.tsximport {renderHook} from '@testing-library/react-native';
import {PlaybackContextProvider} from '@components/VideoPlayerContexts/PlaybackContext';
import {VideoPopoverMenuContextProvider, useVideoPopoverMenuState, useVideoPopoverMenuActions} from '@components/VideoPlayerContexts/VideoPopoverMenuContext';
jest.mock('@libs/ReportUtils', () => ({
getReportOrDraftReport: jest.fn(),
isChatThread: jest.fn(() => false),
}));
jest.mock('@navigation/Navigation', () => ({
isNavigationReady: jest.fn(() => Promise.resolve()),
}));
const wrapper = ({children}: {children: React.ReactNode}) => (
<PlaybackContextProvider>
<VideoPopoverMenuContextProvider>{children}</VideoPopoverMenuContextProvider>
</PlaybackContextProvider>
);
describe('VideoPopoverMenuContextProvider', () => {
it('should provide menu items', () => {
const {result} = renderHook(() => useVideoPopoverMenuState(), {wrapper});
expect(result.current.menuItems).toBeDefined();
expect(Array.isArray(result.current.menuItems)).toBe(true);
});
it('should provide action methods', () => {
const {result} = renderHook(() => useVideoPopoverMenuActions(), {wrapper});
expect(typeof result.current.updateVideoPopoverMenuPlayerRef).toBe('function');
expect(typeof result.current.updatePlaybackSpeed).toBe('function');
expect(typeof result.current.updateSource).toBe('function');
});
it('should throw when useVideoPopoverMenuState is used outside provider', () => {
expect(() => {
renderHook(() => useVideoPopoverMenuState());
}).toThrow('useVideoPopoverMenuState must be used within a VideoPopoverMenuContextProvider');
});
it('should throw when useVideoPopoverMenuActions is used outside provider', () => {
expect(() => {
renderHook(() => useVideoPopoverMenuActions());
}).toThrow('useVideoPopoverMenuActions must be used within a VideoPopoverMenuContextProvider');
});
});tests/ui/components/VolumeContextProviderTest.tsximport {renderHook} from '@testing-library/react-native';
import {PlaybackContextProvider} from '@components/VideoPlayerContexts/PlaybackContext';
import {VolumeContextProvider, useVolumeState, useVolumeActions} from '@components/VideoPlayerContexts/VolumeContext';
jest.mock('@libs/ReportUtils', () => ({
getReportOrDraftReport: jest.fn(),
isChatThread: jest.fn(() => false),
}));
jest.mock('@navigation/Navigation', () => ({
isNavigationReady: jest.fn(() => Promise.resolve()),
}));
const wrapper = ({children}: {children: React.ReactNode}) => (
<PlaybackContextProvider>
<VolumeContextProvider>{children}</VolumeContextProvider>
</PlaybackContextProvider>
);
describe('VolumeContextProvider', () => {
it('should provide volume shared values', () => {
const {result} = renderHook(() => useVolumeState(), {wrapper});
expect(result.current.volume).toBeDefined();
expect(result.current.lastNonZeroVolume).toBeDefined();
});
it('should provide action methods', () => {
const {result} = renderHook(() => useVolumeActions(), {wrapper});
expect(typeof result.current.updateVolume).toBe('function');
expect(typeof result.current.toggleMute).toBe('function');
});
it('should throw when useVolumeState is used outside provider', () => {
expect(() => {
renderHook(() => useVolumeState());
}).toThrow('useVolumeState must be used within a VolumeContextProvider');
});
it('should throw when useVolumeActions is used outside provider', () => {
expect(() => {
renderHook(() => useVolumeActions());
}).toThrow('useVolumeActions must be used within a VolumeContextProvider');
});
});tests/ui/components/WideRHPContextProviderTest.tsximport {renderHook, act} from '@testing-library/react-native';
import Onyx from 'react-native-onyx';
import WideRHPContextProvider, {useWideRHPState, useWideRHPActions} from '@components/WideRHPContextProvider';
import ONYXKEYS from '@src/ONYXKEYS';
import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct';
import wrapOnyxWithWaitForBatchedUpdates from '../../utils/wrapOnyxWithWaitForBatchedUpdates';
jest.mock('@hooks/useRootNavigationState', () => ({
__esModule: true,
default: jest.fn((selector) =>
selector
? selector(undefined)
: {focusedRoute: undefined, focusedNavigator: undefined},
),
}));
const wrapper = ({children}: {children: React.ReactNode}) => (
<WideRHPContextProvider>{children}</WideRHPContextProvider>
);
describe('WideRHPContextProvider', () => {
beforeAll(() => {
Onyx.init({keys: ONYXKEYS});
return waitForBatchedUpdatesWithAct();
});
beforeEach(() => {
wrapOnyxWithWaitForBatchedUpdates(Onyx);
});
afterEach(async () => {
await Onyx.clear();
await waitForBatchedUpdatesWithAct();
});
it('should provide initial state with empty route keys', () => {
const {result} = renderHook(() => useWideRHPState(), {wrapper});
expect(result.current.wideRHPRouteKeys).toEqual([]);
expect(result.current.superWideRHPRouteKeys).toEqual([]);
expect(result.current.isWideRHPFocused).toBe(false);
expect(result.current.isSuperWideRHPFocused).toBe(false);
});
it('should provide all action methods', () => {
const {result} = renderHook(() => useWideRHPActions(), {wrapper});
expect(typeof result.current.showWideRHPVersion).toBe('function');
expect(typeof result.current.showSuperWideRHPVersion).toBe('function');
expect(typeof result.current.removeWideRHPRouteKey).toBe('function');
expect(typeof result.current.removeSuperWideRHPRouteKey).toBe('function');
expect(typeof result.current.markReportIDAsExpense).toBe('function');
expect(typeof result.current.markReportIDAsMultiTransactionExpense).toBe('function');
expect(typeof result.current.unmarkReportIDAsMultiTransactionExpense).toBe('function');
expect(typeof result.current.isReportIDMarkedAsExpense).toBe('function');
expect(typeof result.current.isReportIDMarkedAsMultiTransactionExpense).toBe('function');
expect(typeof result.current.syncRHPKeys).toBe('function');
expect(typeof result.current.clearWideRHPKeys).toBe('function');
expect(typeof result.current.setIsWideRHPClosing).toBe('function');
expect(typeof result.current.setIsSuperWideRHPClosing).toBe('function');
});
it('should mark and check report IDs as expense', () => {
const {result} = renderHook(
() => ({
actions: useWideRHPActions(),
}),
{wrapper},
);
expect(result.current.actions.isReportIDMarkedAsExpense('123')).toBe(false);
act(() => {
result.current.actions.markReportIDAsExpense('123');
});
expect(result.current.actions.isReportIDMarkedAsExpense('123')).toBe(true);
});
it('should mark and unmark report IDs as multi-transaction expense', () => {
const {result} = renderHook(
() => ({
actions: useWideRHPActions(),
}),
{wrapper},
);
expect(result.current.actions.isReportIDMarkedAsMultiTransactionExpense('456')).toBe(false);
act(() => {
result.current.actions.markReportIDAsMultiTransactionExpense('456');
});
expect(result.current.actions.isReportIDMarkedAsMultiTransactionExpense('456')).toBe(true);
act(() => {
result.current.actions.unmarkReportIDAsMultiTransactionExpense('456');
});
expect(result.current.actions.isReportIDMarkedAsMultiTransactionExpense('456')).toBe(false);
});
});tests/ui/components/AttachmentModalContextProviderTest.tsximport {renderHook, act} from '@testing-library/react-native';
import {useContext} from 'react';
import AttachmentModalContext, {AttachmentModalContextProvider} from '@pages/media/AttachmentModalScreen/AttachmentModalContext';
jest.mock('@hooks/useCurrentReportID', () => ({
useCurrentReportIDState: jest.fn(() => ({currentReportID: '1'})),
}));
const wrapper = ({children}: {children: React.ReactNode}) => (
<AttachmentModalContextProvider>{children}</AttachmentModalContextProvider>
);
describe('AttachmentModalContextProvider', () => {
it('should provide context with default values', () => {
const {result} = renderHook(() => useContext(AttachmentModalContext), {wrapper});
expect(typeof result.current.isAttachmentHidden).toBe('function');
expect(typeof result.current.updateHiddenAttachments).toBe('function');
expect(typeof result.current.setCurrentAttachment).toBe('function');
expect(typeof result.current.getCurrentAttachment).toBe('function');
});
it('should track hidden attachments', () => {
const {result} = renderHook(() => useContext(AttachmentModalContext), {wrapper});
expect(result.current.isAttachmentHidden('action1')).toBeFalsy();
act(() => {
result.current.updateHiddenAttachments('action1', true);
});
expect(result.current.isAttachmentHidden('action1')).toBe(true);
});
it('should store and retrieve current attachment', () => {
const {result} = renderHook(() => useContext(AttachmentModalContext), {wrapper});
expect(result.current.getCurrentAttachment()).toBeUndefined();
const mockAttachment = {source: 'https://example.com/image.png', reportID: '123'};
act(() => {
result.current.setCurrentAttachment(mockAttachment);
});
expect(result.current.getCurrentAttachment()).toEqual(mockAttachment);
});
});tests/ui/components/ExpensifyCardContextProviderTest.tsximport {renderHook, act} from '@testing-library/react-native';
import ExpensifyCardContextProvider, {useExpensifyCardState, useExpensifyCardActions} from '@pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardContextProvider';
jest.mock('@hooks/useNonPersonalCardList', () => ({
__esModule: true,
default: jest.fn(() => ({})),
}));
const wrapper = ({children}: {children: React.ReactNode}) => (
<ExpensifyCardContextProvider>{children}</ExpensifyCardContextProvider>
);
describe('ExpensifyCardContextProvider', () => {
it('should provide initial empty state', () => {
const {result} = renderHook(() => useExpensifyCardState(), {wrapper});
expect(result.current.cardsDetails).toEqual({});
expect(result.current.isCardDetailsLoading).toEqual({});
expect(result.current.cardsDetailsErrors).toEqual({});
});
it('should provide setter actions', () => {
const {result} = renderHook(() => useExpensifyCardActions(), {wrapper});
expect(typeof result.current.setCardsDetails).toBe('function');
expect(typeof result.current.setIsCardDetailsLoading).toBe('function');
expect(typeof result.current.setCardsDetailsErrors).toBe('function');
});
it('should update card details via actions', () => {
const {result} = renderHook(
() => ({
state: useExpensifyCardState(),
actions: useExpensifyCardActions(),
}),
{wrapper},
);
act(() => {
result.current.actions.setCardsDetails({1: {pan: '4111', cvv: '123', expiration: '12/25', pinSet: true}});
});
expect(result.current.state.cardsDetails).toHaveProperty('1');
});
it('should update loading state via actions', () => {
const {result} = renderHook(
() => ({
state: useExpensifyCardState(),
actions: useExpensifyCardActions(),
}),
{wrapper},
);
act(() => {
result.current.actions.setIsCardDetailsLoading({1: true});
});
expect(result.current.state.isCardDetailsLoading).toEqual({1: true});
});
});tests/ui/components/TravelCVVContextProviderTest.tsximport {renderHook, act} from '@testing-library/react-native';
import TravelCVVContextProvider, {useTravelCVVState, useTravelCVVActions} from '@pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider';
const wrapper = ({children}: {children: React.ReactNode}) => (
<TravelCVVContextProvider>{children}</TravelCVVContextProvider>
);
describe('TravelCVVContextProvider', () => {
it('should provide initial state with null CVV and no loading', () => {
const {result} = renderHook(() => useTravelCVVState(), {wrapper});
expect(result.current.cvv).toBeNull();
expect(result.current.isLoading).toBe(false);
expect(result.current.validateError).toEqual({});
});
it('should provide setter actions', () => {
const {result} = renderHook(() => useTravelCVVActions(), {wrapper});
expect(typeof result.current.setCvv).toBe('function');
expect(typeof result.current.setIsLoading).toBe('function');
expect(typeof result.current.setValidateError).toBe('function');
});
it('should update CVV via action', () => {
const {result} = renderHook(
() => ({
state: useTravelCVVState(),
actions: useTravelCVVActions(),
}),
{wrapper},
);
act(() => {
result.current.actions.setCvv('123');
});
expect(result.current.state.cvv).toBe('123');
});
it('should update loading state via action', () => {
const {result} = renderHook(
() => ({
state: useTravelCVVState(),
actions: useTravelCVVActions(),
}),
{wrapper},
);
act(() => {
result.current.actions.setIsLoading(true);
});
expect(result.current.state.isLoading).toBe(true);
});
it('should update validation errors via action', () => {
const {result} = renderHook(
() => ({
state: useTravelCVVState(),
actions: useTravelCVVActions(),
}),
{wrapper},
);
const errors = {cvv: 'Invalid CVV'};
act(() => {
result.current.actions.setValidateError(errors);
});
expect(result.current.state.validateError).toEqual(errors);
});
}); |
|
@MelvinBot I asked for manual test steps, not unit tests |
|
Sorry about that! Here are manual test steps for each provider moved in this PR: KeyboardDismissibleFlatListContextProvider
KYCWallContextProvider
ProductTrainingContextProvider
SearchRouterContextProvider
SupportalPermissionDeniedModal
FullScreenContextProvider
PlaybackContextProvider
VideoPopoverMenuContextProvider
VolumeContextProvider
WideRHPContextProvider
AttachmentModalContextProvider
ExpensifyCardContextProvider
TravelCVVContextProvider
|
@shubham1206agra I think mWeb and native app recordings are still needed to fully verify the behavior during testing or at least one of them. |
|
#86441 (comment) @shubham1206agra Just checking, have you gone through all the features in these test steps? If not, please double-check to make sure everything works as expected after the provider move. |
@huult I have already done that internally. Also this PR already went through QA already |
@huult I actually went through all of them except TravelCVVContextProvider as I don't have Travel card |
@shubham1206agra I’ll finish the review shortly. |
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🚧 @roryabraham has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
🚀 Deployed to staging by https://github.com/roryabraham in version: 9.3.60-0 🚀
Bundle Size Analysis (Sentry): |
|
I reviewed the changes in this PR. It moves auth-only React context providers from No help site documentation changes are required. |
|
🚀 Deployed to production by https://github.com/mountiny in version: 9.3.60-22 🚀
|
Explanation of Change
Fixed Issues
$ #86458
Tests
Offline tests
Same as Tests
QA Steps
Same as Tests
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectioncanBeMissingparam foruseOnyxtoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Screen.Recording.2026-03-28.at.1.53.03.PM.mov