Skip to content

[ECUK In-App 3DS] MFA: isolate 3DS flow into self-contained modal navigator#89992

Open
dariusz-biela wants to merge 65 commits into
Expensify:mainfrom
software-mansion-labs:dariusz-biela/feat/3ds/internal-navigation-v3
Open

[ECUK In-App 3DS] MFA: isolate 3DS flow into self-contained modal navigator#89992
dariusz-biela wants to merge 65 commits into
Expensify:mainfrom
software-mansion-labs:dariusz-biela/feat/3ds/internal-navigation-v3

Conversation

@dariusz-biela
Copy link
Copy Markdown
Contributor

@dariusz-biela dariusz-biela commented May 8, 2026

Explanation of Change

MFA screens (3DS prompt, magic code, outcome) used to live inside RightModalNavigator as regular RHP routes. Because MFA state is held in React context (not Onyx), URL-driven routes could outlive the state they depended on — a reload, deep link, or unrelated RHP navigation could leave the user on a half-initialized MFA screen, exactly the kind of broken state #81021 asks for guards against.

New architecture. MFA is moved out of the RHP into a self-contained MultifactorAuthenticationModalNavigator, mounted as a sibling of the root stack inside a NavigationIndependentTree. The MFA context provider wraps the authenticated app, so any screen can call executeScenario and the navigator mounts on top of whatever is currently visible. MFA routes are removed from ROUTES / SCREENS / linking config; internal navigation between MFA screens goes through a dedicated mfaNavigation module instead of the global Navigation API.

How it behaves. The MFA flow no longer touches the URL or the app navigation state — the page underneath (e.g. transaction-preview) stays put while MFA runs on top.

  • A page refresh mid-flow drops the user back at the trigger screen with a clean slate; the flow can simply be restarted. This trades URL persistence for the guarantee that nobody is ever stranded on a screen whose context state has been wiped (and the underlying commands are rate-limited, so restarting is safe).
  • Browser back and Android hardware back surface the scenario's cancel-confirm modal via a custom history marker (CUSTOM_HISTORY_ENTRY_MFA_MODAL_NAVIGATOR, mirroring the side-panel pattern) instead of tearing the flow down.
  • On success/failure the navigator unmounts and the underlying screen routes the user wherever the scenario decided (e.g. back to the report).

All cancel entry points — backdrop, header back, hardware back, browser back, offline — funnel through a single requestCancel decision in the MFA context, so there is one source of truth for "should this attempt to close prompt a confirm, run cancel, or just close?".

Side cleanups.

  • BiometricsTestPage removed — the dev tool now calls executeScenario directly, so the standalone page no longer has a reason to exist.
  • Biometrics test row gated to authenticated sessionsuseMultifactorAuthentication() is only valid under AuthScreens; reaching the test-tools modal from SignInPage previously crashed.
  • Test/Revoke buttons disabled while offlineprocess() short-circuits on isOffline, so without the guard the user would be stranded on the transparent placeholder with no way out.
  • onClose override on OutcomeScreenBaseAuthorizeTransactionPage (deny outcome) and ChangePINAtATMPage render the screen inside the RHP, outside the MFA navigator; the default CLOSE_MODAL dispatch was a no-op there and left the buttons inert.
  • Context module split into leaf files — the previous import graph formed a cycle that resolved customConfig(undefined) at module-load and crashed the sortTransactionsPending3DSReview test suite.

Fixed Issues

$ #81021
PROPOSAL:

Tests

Tester access note. I only have hands-on access to the Biometrics Test scenario (run from the in-app Test Tool Menu) and ran the Authorize Transaction scenario against the mocked outcome handler. The remaining card-related scenarios (SET-PIN-ORDER-CARD, REVEAL-PIN, CHANGE-PIN) and an end-to-end Authorize Transaction against the real backend require someone with access to an Expensify Card, or with the ability to run a properly configured backend in developer mode.

General checks (run for every scenario below):

  • Header back button → cancel-confirm modal
  • "Got it" button on success/failure outcome → closes flow, returns to trigger screen
  • Android hardware back → cancel-confirm modal (does not tear the flow down)
  • Browser back (web) → cancel-confirm modal
  • Backdrop tap → cancel-confirm modal
  • Re-run back-to-back works (clean slate each time)
  • Rapid double-tap on the trigger does NOT start two flows
  • Refresh mid-flow → app returns to trigger screen, scenario re-runnable
  • App close + reopen mid-flow → trigger screen, no stale MFA UI
  • Offline cancel paths close cleanly (modal does not stick open)
  • No JS console errors / Sentry-visible BackHandler warnings

Per-scenario (happy + failure + any scenario-specific checks):

  • BIOMETRICS-TEST — happy / failure

  • AUTHORIZE-TRANSACTION — happy / failure; deny outcome rendered inline in RHP — "Got it" / header back close the RHP (no dead button)

  • SET-PIN-ORDER-CARD — happy / failure

  • REVEAL-PIN — happy / failure

  • CHANGE-PIN — happy / failure

  • Verify that no errors appear in the JS console

Offline tests

QA Steps

// TODO: These must be filled out, or the issue title must include "[No QA]."

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

MFA screens (magic code, prompt, outcome) lived inside
RightModalNavigator as regular RHP routes. This created coupling
between MFA flow lifecycle and app navigation state — cancelling,
refreshing, or deep-linking could leave stale MFA routes in the stack.

Move MFA to a self-contained overlay with its own NavigationIndependentTree
and dedicated mfaNavigation module. The overlay mounts/unmounts based on
MFA context state, uses the Expensify modal card-style interpolator for
slide-from-right animation (same as RHP), and is fully decoupled from
the app navigation tree.

- Remove MFA routes from ROUTES.ts, linking config, and RHP navigator
- Delete BiometricsTestPage (test button now calls executeScenario directly)
- Add mfaNavigation.ts with deferred push for first-screen slide animation
- Add MultifactorAuthenticationOverlay as sibling to RootStack

Refs: Expensify#81021
- Remove unreachable NOT_FOUND screen (registered but never navigated to)
- Replace offscreen style hack with early return null when not visible
- Trim verbose comments to essential context
- Remove redundant conditional guards (component returns null early)
CLOSE_MODAL sets isModalOpen=false without clearing scenario data,
letting the overlay play its exit animation while screens remain
mounted. RESET fires only after the animation completes, preventing
the snap-disappear on close.

- Add isModalOpen to MFA state, set true on INIT
- Add CLOSE_MODAL action (keeps scenario/data intact)
- Overlay drives visibility from isModalOpen instead of !!scenario
- Overlay dispatches RESET after close animation callback
- UI dispatch sites (outcome close, cancel, skip-outcome) use
  CLOSE_MODAL instead of RESET
goBack() on the inner navigator triggers the Stack's reverse
slide-from-right animation while the backdrop fades simultaneously.
Previously the screen stayed static during the backdrop fade and
then snap-disappeared on unmount.

return (
<NarrowPaneContextProvider>
<MultifactorAuthenticationContextProviders>
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this file, I only removed the MultifactorAuthenticationContextProviders, and because it was a wrapper, the GitHub diff shows that many changes.

- Use scheduleOnRN instead of deprecated runOnJS
- Use progress.set()/get() instead of deprecated .value
- Add sentryLabel via CONST.SENTRY_LABEL.MFA_OVERLAY.BACKDROP
- Fix import alias in TestToolMenu (@components → ./)
- Remove unused eslint-disable in stateReducer
- Suppress set-state-in-effect in overlay and ValidateCodePage
  with justification comments
Replace the imperative isVisible setState-in-effect pattern with a render-time
prevIsModalOpen mirror and a derived isVisible. The effect now lists every
referenced dep, removing the exhaustive-deps and set-state-in-effect disables.
Three iOS-specific issues addressed:

- Navigation buffer race: on iOS native-stack, INITIAL.onLayout fires
  synchronously on mount — before process() reaches navigate() — so
  applyPendingNavigation runs with an empty buffer, and the buffered
  push set by navigate() never fires. Add a hasInitialLaidOut module
  flag; once true, navigate() pushes directly instead of buffering.

- White flash on first open: INITIAL_SCREEN inherited the opaque app
  background from screenOptions.native.contentStyle. Set explicit
  transparent contentStyle (and cardStyle for web) per-screen.

- Crash on close: the original close effect used a withTiming
  completion callback that called scheduleOnRN to update React state
  on the JS thread. The worklet → JS bridge raced against the native
  pop animation triggered by goBack(), and the resulting unmount
  hit an intermediate native-stack state and exited the app.
  Replace the worklet callback path with a plain setTimeout, and
  return a cleanup that clears the timer if the component
  unmounts mid-animation.
Use TRANSPARENT_MODAL presentation, disable the default react-navigation
card overlay, and switch the cardStyleInterpolator to forHorizontalIOS
with a Safari-only fallback to the Expensify modal interpolator. The
previous always-custom interpolator was justified by a width=0 race that
no longer applies — the TransparentScreen placeholder guarantees a
measured layout before the first push.
Dispatch SET_FLOW_COMPLETE alongside CLOSE_MODAL so the state-machine
guard in process() short-circuits if any dep field changes during the
300ms exit animation (e.g. user taps the fading backdrop and triggers
cancel(), which dispatches SET_ERROR).
Stack history is always [INITIAL, <current>] because non-initial
navigations replace the top. iOS swipe-back / Android hardware back
popped the active screen and left the transparent INITIAL placeholder
focused with the overlay still open, trapping the user on narrow
layouts where no backdrop exists. Setting gestureEnabled: false on the
shared screenOptions blocks the pop; users still exit via the header
back button or backdrop, both of which dispatch CLOSE_MODAL.
clearPendingNavigation() reset pendingNavigation but left
hasInitialLaidOut as true across reopens. On web, ResizeObserver-backed
onLayout fires async after isReady becomes true, opening a window where
navigate() would see a stale hasInitialLaidOut and dispatch a direct
push before the INITIAL_SCREEN had laid out for the new session,
breaking the slide-in interpolator measurement.
Single `as MultifactorAuthenticationScenarioConfig` is sufficient — the
`as unknown as` form weakens type safety with no compiler benefit.
…nd overlay

The overlay-internal param list type was duplicated. Export it from
mfaNavigation.ts and import it in MultifactorAuthenticationOverlay so the
type has a single source of truth.
Add mfaOverlayZIndex to styles/variables and use it in the overlay root
style instead of a hardcoded 1000.
Drop useMemo/useCallback usage in MultifactorAuthenticationOverlay. The
project relies on React Compiler for automatic memoization, so the manual
wrappers added complexity without a clear correctness benefit.
Align with the rest of the overlay components (MultifactorAuthenticationOverlay,
OutcomeScreenBase, ...) that all set displayName for nicer debug output.
Replace the `as Record<string, unknown> | undefined` cast with a typed
variable annotation so TypeScript proves the assignment instead of forcing
it.
Project convention (contributingGuides/STYLING.md) requires non-theme
static styles to live in src/styles. Remove the local StyleSheet.create
in MultifactorAuthenticationOverlay and expose mfaOverlayRoot through
useThemeStyles instead.
…odalNavigator

Align the standalone MFA navigator with the *ModalNavigator naming used by
peers in src/libs/Navigation/AppNavigator/Navigators/ (RightModalNavigator,
OnboardingModalNavigator, etc.). The component owns a stack of screens, so
"ModalNavigator" describes it more accurately than "Overlay" and avoids
collision with the existing Overlay component (backdrop dimmer).

Renames:
- File MultifactorAuthenticationOverlay.tsx -> MultifactorAuthenticationModalNavigator.tsx
- Component MultifactorAuthenticationOverlay -> MultifactorAuthenticationModalNavigator
- Type MultifactorAuthenticationOverlayParamList -> MultifactorAuthenticationModalNavigatorParamList
- Type MfaOverlayInternalParamList -> MultifactorAuthenticationModalNavigatorInternalParamList
- Style mfaOverlayRoot -> mfaModalNavigatorRoot
- Variable mfaOverlayZIndex -> mfaModalNavigatorZIndex

"Overlay" is reserved for the backdrop layer (MFA_OVERLAY.BACKDROP Sentry
label, backdropAnimatedStyle), which is its actual role here.
…r self-doc

- clearPendingNavigation -> resetMfaNavigation. The function resets two pieces
  of module state (pendingNavigation and hasInitialLaidOut), not just the
  pending request. The old name invited callers to assume a narrower contract,
  which risked silently zeroing the laid-out flag.
- applyPendingNavigation -> handleInitialScreenLayout. It is wired to the
  placeholder's onLayout; naming it as a layout handler matches the callsite
  and surfaces the side effect (flushing buffered nav) as a layout consequence
  rather than the primary semantic.
- progress -> backdropProgress. The shared value drives only the backdrop
  opacity; the slide is owned by the Stack. The specific name prevents future
  reuse for card-style transitions.
Comment thread src/styles/index.ts Outdated
Comment thread src/components/MultifactorAuthentication/mfaNavigation.ts Outdated
Browser back and hardware back now surface the scenario's cancel-confirm
modal instead of immediately tearing down the MFA flow. The synthetic
CUSTOM_HISTORY_ENTRY_MFA_MODAL_NAVIGATOR marker mirrors the existing
side-panel pattern, and the new useSyncMfaModalNavigatorWithHistory hook
isolates the marker push/pop, back-press detection, and confirm-state
plumbing from the navigator. Confirming runs cancel; rejecting keeps the
flow open with the URL pointer unchanged. Forward stack is truncated on
confirm so the cancelled flow cannot be resurrected.

addRootHistoryRouterExtension now preserves the contiguous trailing run
of known markers through rehydration, generalising the previous
side-panel-only branch.
Each MFA screen previously owned its own cancel-confirmation state and
back-press logic. Move it into the context so there is a single decision
point shared by hardware/browser back, header back button, focus-trap
escape, and the backdrop press.

- Add requestCancel/hideCancelConfirm/confirmCancel to the MFA context.
  requestCancel decides — based on isFlowComplete, scenario, isOffline —
  whether to close the modal, cancel directly, or surface the confirm.
- Move isCancelConfirmVisible into the reducer state to keep the
  state/actions context split lint rule happy.
- Render the cancel-confirmation modal once in the MFA navigator;
  PromptPage and ValidateCodePage no longer own confirm-modal state.
- Move useSyncMfaModalNavigatorWithHistory into the context and split it
  into two effects so the marker lifecycle is not churned when
  requestCancel changes.
- Collapse handleCallback so SET_FLOW_COMPLETE dispatches once and the
  outcome-screen branch uses a ternary.
… modalBaseZIndex

- Rename INITIAL_SCREEN to MFA_INITIAL_SCREEN to disambiguate at import sites.
- Replace one-off mfaModalNavigatorZIndex with the existing modalBaseZIndex
  variable; drop the now-unused entry from variables.ts.
Resolved conflicts:
- src/CONST/index.ts: kept both MFA_OVERLAY and DOMAIN SENTRY_LABEL additions
- src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx: took main version, removed MultifactorAuthenticationContextProviders wrap and its import (provider hoisted to AuthScreens by this PR)
Offline requestCancel previously dispatched SET_ERROR, but process() short-circuits on isOffline, leaving the modal stuck open with no path to handleCallback. Centralize the offline-close decision in cancel() so every entry point (backdrop, hardware back, browser back, confirmCancel) exits the flow immediately when offline.
TestToolMenu called useMultifactorAuthentication() unconditionally, but
the provider is only mounted under AuthScreens. Reaching the test-tools
modal from SignInPage (4-tap PanResponder) threw "must be used within a
MultifactorAuthenticationContextProviders".

Move the biometrics row into BiometricsTestToolRow, rendered only when
isAuthenticated === true, so the context hook is not invoked pre-auth.

Update TestToolMenuBiometricsTest.tsx: mock the new context, drop dead
mocks (useSingleExecution, useWaitForNavigation, Navigation), and add
executeScenario(BIOMETRICS_TEST) assertion to the Test-button case.
The MFA outcome screen is pushed by the Context after the scenario callback resolves.
On Android, when a callback initiates an RHP transition (e.g. CHANGE_PIN's goBack to
pop ChangePINPage), the RHP slide-out raced the outcome-screen push and leaked through
the outgoing screen. Wrap mfaNavigate in TransitionTracker.runAfterTransitions so the
outcome push waits for any active transition to end; if none are active it fires
synchronously and other scenarios are unaffected.
After main's refactor of ModalCardStyleInterpolatorProps, enter became required.
Pass {kind: 'slide-from-width'} to preserve the previous behavior — interpolator
uses variables.sideBarWidth as the slide range on wide layout, matching the
comment about stable slide range regardless of layout timing.
…vel API

ESLint restricts direct imports of TransitionTracker — it's an internal
primitive that should only be accessed through Navigation/KeyboardUtils/etc.
Add a thin Navigation.runAfterTransition helper that wraps
TransitionTracker.runAfterTransitions and use it from the MFA Context so the
import restriction is satisfied without changing behavior.
…s/internal-navigation-v3

# Conflicts:
#	src/components/MultifactorAuthentication/config/scenarios/RevealPIN.tsx
@dariusz-biela
Copy link
Copy Markdown
Contributor Author

@chuckdries, I resolved the conflicts and fixed the ESLint error.

However, I only did a very quick test of the entire flow at the very end, and I’m not sure if the screen flickering bugs are actually fixed in this PR.

Before you merge this, it would be good to test everything thoroughly, and if any more bugs come up, please report them to me. I’ll try to make a final round of fixes on Monday, and if everything looks good, you can go ahead and merge it.

@chuckdries
Copy link
Copy Markdown
Contributor

Reported android issues are both solved

🟢 Reveal PIN works
MFA.refactor.reveal.pin.android.2.success.mp4
🟢 change PIN cancel shows correct transition
MFA.refactor.change.pin.android.2.pass.mp4

Proceeding with testing on other platforms

@chuckdries
Copy link
Copy Markdown
Contributor

Android mweb chrome tests - mostly working. One main issue is that reveal PAN has the same issue that reveal PIN had yesterday. A minor NAB I've seen is that pressing the hardware back button dismisses the outcome page and navigates whatever is underneath.

🔴 reveal PAN does not reveal PAN
MFA.refactor.reveal.PAN.mweb.chrome.fail.mp4
⚪ authorize transaction "got it" button vs hardware back button behave differently on failure outcome page (works fine otherwise)
MFA.refactor.authorize.transaction.mweb.chrome.back.button.vs.got.it.mp4
⚪ change PIN "got it" button vs hardware back button behave differently on failure outcome page (works fine otherwise)
MFA.refactor.change.PIN.mweb.chrome.back.button.vs.got.it.behave.differently.mp4
🟢 test scenario works fine
MFA.refactor.test.mweb.chrome.success.mp4
MFA.refactor.test.mweb.chrome.back.buttons.mp4
🟢 reveal PIN works fine
MFA.refactor.reveal.PIN.mweb.chrome.pass.mp4

@chuckdries
Copy link
Copy Markdown
Contributor

chuckdries commented May 22, 2026

iOS native has the same issue with reveal PAN not working. Also, I have twice observed an issue where, after navigating away from the failure outcome screen, the whole app apparently freezes, but I can't get a consistent reproduction

🟢 test scenario works fine
MFA.refactor.test.ios.pass.mp4
🟢 reveal PIN works fine
MFA.refactor.reveal.PIN.ios.success.pass.mp4
MFA.refactor.reveal.PIN.ios.back.button.pass.mp4
🟢 change PIN works fine
MFA.refactor.change.PIN.ios.reject.mp4
MFA.refactor.change.PIN.ios.success.pass.mp4
🟢 set PIN works, but is a case where I caught the whole app freeze behavior on video
MFA.refactor.set.PIN.success.pass.mp4
MFA.refactor.set.PIN.ios.whole.app.freeze.after.reject.mp4
🟢 authorize transaction works fine
MFA.refactor.authorize.transaction.ios.success.pass.mp4
🔴 reveal PAN does not work
MFA.refactor.reveal.PAN.ios.fail.mp4
MFA.refactor.reveal.PAN.ios.reject.pass.mp4

@chuckdries
Copy link
Copy Markdown
Contributor

MacOS Chrome has three issues, two of them already documented on other platforms (and only one blocking)

🔴 Reveal PAN doesn't work
MFA.refactor.reveal.PAN.macos.chrome.outcome.page.history.buttons.mp4
⚪ Using the browser back button to dismiss the outcome page when there's an RHP below it closes the RHP (vs "Got it" just closes the MFA modal)
MFA.refactor.change.PIN.macos.chrome.back.reject.outcome.button.behavior.diff.mp4
⚪ Using the browser back button to dismiss the outcome page causes the forward button to become enabled, though pressing it does nothing
MFA.refactor.test.macos.chrome.browser.forward.button.enabled.mp4

Same root cause as de770f8 for REVEAL_PIN. The success callback called
Navigation.closeRHPFlow() + Navigation.navigate(SETTINGS_WALLET_DOMAIN_CARD)
right after setRevealedVirtualCardDetails(). With MFA now mounted as a
sibling navigator, ExpensifyCardPage stays focused throughout the flow,
so closing the RHP unmounted it and ran its useFocusEffect cleanup, which
calls clearRevealedVirtualCardDetails() and wiped the PAN/expiration/CVV
before the page remounted.

Drop the navigation calls — the underlying card page stays put and just
re-renders with the new details. SKIP_OUTCOME_SCREEN still closes the MFA
modal cleanly.
@dariusz-biela
Copy link
Copy Markdown
Contributor Author

dariusz-biela commented May 25, 2026

🔴 Reveal PAN doesn't work

Fixed.

@dariusz-biela
Copy link
Copy Markdown
Contributor Author

⚪ Using the browser back button to dismiss the outcome page causes the forward button to become enabled, though pressing it does nothing

Unfortunately, this is the only trade-off we had to accept, because it’s not possible to remove items from the browser history.

So we'll have to leave it at that.

The MFA navigator refactor left a stale entry for
RightModalNavigator.tsx in eslint.seatbelt.tsv. The current code no
longer triggers react/jsx-props-no-spreading, so the entry was
unreachable and SEATBELT_FROZEN=1 lint passes after removal.
When an MFA scenario callback pops a route before the outcome screen
(e.g. ChangePIN), `useLinking` reconciles the new focused path via
`history.go(delta)` and discards the marker's synthetic browser entry.
`state.history` still carries the marker (router extensions preserve
it), but the browser no longer has a matching entry — so a later
browser-back lands on an older snapshot and skips past the screen the
user expects.

Bracket the pop with a strip → pop → re-attach cycle on the trailing
marker (`popAndRealignMfaMarker`). Each toggle drives useLinking to
push or replace a fresh browser entry mapped to the post-pop path. The
MFA sync hook reads `isMfaMarkerStripInProgress` so it does not treat
the transient removal as a back-press and fire `requestCancel`
mid-pop.

Implementation notes:
- `pop` callback is `() => void`; the `canGoBack` guard is hoisted out
  of the wrapper so its semantics are "always pops".
- `pop` runs inside try/finally so the re-attach + flag reset still
  fire if the pop callback throws (no stuck `stripInProgress` flag).
- Marker dispatch is centralized in `toggleMfaMarker` and reused by
  the sync hook to drop a duplicate inline dispatch.
@dariusz-biela
Copy link
Copy Markdown
Contributor Author

Android mweb chrome tests - mostly working. One main issue is that reveal PAN has the same issue that reveal PIN had yesterday. A minor NAB I've seen is that pressing the hardware back button dismisses the outcome page and navigates whatever is underneath.

  • ⚪ authorize transaction "got it" button vs hardware back button behave differently on failure outcome page (works fine otherwise)
  • ⚪ change PIN "got it" button vs hardware back button behave differently on failure outcome page (works fine otherwise)

MacOS Chrome has three issues, two of them already documented on other platforms (and only one blocking)

  • ⚪ Using the browser back button to dismiss the outcome page when there's an RHP below it closes the RHP (vs "Got it" just closes the MFA modal)

That should be fixed by now, after a lot of trouble.

Overall, the browser state and React Navigation got out of sync in this flow. So after calling goBack, I had to remove the MFA state from React Navigation and then add it back to both React Navigation and the browser.

`popAndRealignMfaMarker` schedules a re-attach after pop's transition.
If `isModalOpen` flips to false during that window (e.g. a future
scenario whose callback calls `Navigation.goBack` and returns
`SKIP_OUTCOME_SCREEN`), the scheduled re-attach would inject the
marker back into history after the modal is already gone.

Expose `cancelPendingMfaMarkerReattach` and call it from the sync
hook's close cleanup. The cancel is implemented by flipping
`stripInProgress` to false — the scheduled callback then no-ops on
its own gate check, so no separate handle reference is needed.
…elper

Move the transition-aware scheduler out of mfaModalMarkerPreservation
and into the goBack call site, so the helper no longer imports the
internal TransitionTracker primitive that no-restricted-imports forbids
outside Navigation itself.
@DylanDylann
Copy link
Copy Markdown
Contributor

DylanDylann commented May 25, 2026

On the failure/success screen, clicking outside the RHP causes the card detail page to appear instead of closing all RHPs

Screen.Recording.2026-05-25.at.23.50.51.mov

@DylanDylann
Copy link
Copy Markdown
Contributor

Transition to success screen doesn't close the discard modal (not happening on main)

Screen.Recording.2026-05-25.at.23.55.24.mov

@DylanDylann
Copy link
Copy Markdown
Contributor

DylanDylann commented May 25, 2026

Testing preferences modal should overlap above the MFA RHPs

Screenshot 2026-05-26 at 00 11 41

Updated: ahh wait... I am not sure if this is a bug because there is a button to test MFA in the testing preferences modal

Screen.Recording.2026-05-26.at.00.16.21.mov

@rafecolton rafecolton self-requested a review May 25, 2026 17:21
Comment on lines +123 to +127
const cleanupTimer = setTimeout(() => {
resetMfaNavigation();
setPhase('closed');
dispatch({type: 'RESET'});
}, CONST.ANIMATED_TRANSITION);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we replace the setTimeout in the cleanup with something else, like a transition-end listener, so the reset isn't tied to wall clock time?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed the problem with this commit 77075bb

@dariusz-biela
Copy link
Copy Markdown
Contributor Author

dariusz-biela commented May 26, 2026

Transition to success screen doesn't close the discard modal (not happening on main) (#89992 (comment))

Fixed.

@dariusz-biela
Copy link
Copy Markdown
Contributor Author

dariusz-biela commented May 26, 2026

On the failure/success screen, clicking outside the RHP causes the card detail page to appear instead of closing all RHPs (#89992 (comment))

Testing preferences modal should overlap above the MFA RHPs. Updated: ahh wait... I am not sure if this is a bug because there is a button to test MFA in the testing preferences modal (#89992 (comment))

@rafecolton @chuckdries

@DylanDylann noticed some potential differences/issues compared to the implementation in the main branch.

Personally, I think the current implementation in this PR is fine, and possibly even better than what’s in the main branch, but I don’t have any strong opinions on the matter.

What do you all think about this? How should we ultimately implement this?

@DylanDylann
Copy link
Copy Markdown
Contributor

It's great to confirm again 💯

@DylanDylann
Copy link
Copy Markdown
Contributor

Testing preferences modal should overlap above the MFA RHPs. Updated: ahh wait... I am not sure if this is a bug because there is a button to test MFA in the testing preferences modal

I don't mind this problem; either way is fine to me

@DylanDylann
Copy link
Copy Markdown
Contributor

On the failure/success screen, clicking outside the RHP causes the card detail page to appear instead of closing all RHPs (#89992 (comment))

For this problem, I think we should only redirect back to the card detail page if users click on the back button or the Got it button

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants