Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
6dabb95
feature: show discard modal when leaving odometer tab with unsaved ch…
jakubkalinski0 May 5, 2026
0e73202
fix: don't show discard modal when tapping the active distance tab
jakubkalinski0 May 11, 2026
203f488
fix: prevent distance tab switch while discard modal is opening
jakubkalinski0 May 11, 2026
bb99470
fix: catch tab-discard callback errors before jumping tabs
jakubkalinski0 May 11, 2026
150381e
fix: handle synchronous throws from tab discard callback
jakubkalinski0 May 11, 2026
196b7b7
fix: prevent false discard modal after editing odometer from confirma…
jakubkalinski0 May 11, 2026
fed845d
fix: pull missing hasInitializedRefs ref into odometer step
jakubkalinski0 May 13, 2026
e6b1375
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 May 13, 2026
d1f2908
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 May 13, 2026
2f6636a
chore: prettier run
jakubkalinski0 May 13, 2026
2c9bc31
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 May 28, 2026
6f54b10
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 May 28, 2026
8fabe0b
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 Jun 1, 2026
c76d61c
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 Jun 2, 2026
534b663
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 Jun 8, 2026
4ab9a73
fix: web browser-back now shows the discard modal for odometer tab
jakubkalinski0 Jun 8, 2026
22b230e
fix: restore input focus on tab-switch discard cancel and use getOdom…
jakubkalinski0 Jun 8, 2026
b089b7c
refactor: tidy odometer tab-discard guards
jakubkalinski0 Jun 8, 2026
88c5868
refactor: extract odometer resync decision logic into tested predicates
jakubkalinski0 Jun 9, 2026
4b34bea
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 Jun 9, 2026
ebd7849
fix(odometer): remove duplicate edit-from-confirmation backup effect …
jakubkalinski0 Jun 12, 2026
3a95d0f
fix(odometer): make discard-changes diff re-mint-invariant via image …
jakubkalinski0 Jun 12, 2026
fd2a38e
fix(odometer): guard resync from re-hydrating user-cleared readings o…
jakubkalinski0 Jun 12, 2026
12269b9
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 Jun 12, 2026
b174200
revert(odometer): drop web browser-back discard handling, keep tab-sw…
jakubkalinski0 Jun 12, 2026
abef5e3
chore: prettier run
jakubkalinski0 Jun 12, 2026
08b5591
chore: add reminted to cspell wordlist
jakubkalinski0 Jun 12, 2026
572d2e7
feat(navigation): add generic tab-switch discard guard to OnyxTabNavi…
jakubkalinski0 Jun 15, 2026
3ec31e7
refactor(odometer): adopt generic tab guard, drop DistanceTabGuardCon…
jakubkalinski0 Jun 15, 2026
fd2b2fb
chore(odometer): typed test factories, drop lint suppressions, trim c…
jakubkalinski0 Jun 15, 2026
a6961d3
chore(odometer): trim redundant and verbose comments
jakubkalinski0 Jun 15, 2026
2ad7a85
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 Jun 15, 2026
dd02532
docs: condense comments in discard-changes confirmation and odometer …
jakubkalinski0 Jun 15, 2026
6b7b41d
chore: prettier run
jakubkalinski0 Jun 15, 2026
b32e057
chore(odometer): toast on tab-switch discard failure, typed test fact…
jakubkalinski0 Jun 16, 2026
f4576b0
fix: include lastModified in odometer image identity to detect same-n…
jakubkalinski0 Jun 16, 2026
290f76a
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 Jun 16, 2026
1e088c9
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 Jun 16, 2026
642ec62
chore(lint): revert manual eslint-seatbelt baseline edits
jakubkalinski0 Jun 18, 2026
7d151ec
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 Jun 18, 2026
20b3614
chore(lint): satisfy no-unsafe-type-assertion in odometer test helper
jakubkalinski0 Jun 19, 2026
4498039
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 Jun 19, 2026
bddab28
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 Jun 22, 2026
86b587d
refactor(odometer): drop dead isEditingConfirmation branch in tab-swi…
jakubkalinski0 Jun 22, 2026
ba31d18
refactor(odometer): consolidate duplicate resync effects into useOdom…
jakubkalinski0 Jun 22, 2026
3dbe51e
fix(odometer): show discard-changes prompt on tab switch after reload
jakubkalinski0 Jun 23, 2026
04a1853
chore: prettier
jakubkalinski0 Jun 23, 2026
4def23d
chore: remove unused eslint-disable directive in useOdometerReadingsS…
jakubkalinski0 Jun 23, 2026
fd3b5d7
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 Jun 24, 2026
9f3f475
fix(odometer): detect same-name/same-size image swaps on native
jakubkalinski0 Jun 24, 2026
baae020
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 Jun 24, 2026
eae899a
fix(odometer): make image-identity uri fallback native-only
jakubkalinski0 Jun 24, 2026
b64dcc9
chore(odometer): reword comment to satisfy cspell
jakubkalinski0 Jun 24, 2026
9d90e25
fix(odometer): don't treat image-only changes as reading resyncs
jakubkalinski0 Jun 24, 2026
0039a98
Merge branch 'main' into jakubkalinski0/Odometer_show_discard_changes…
jakubkalinski0 Jun 25, 2026
df15a9c
refactor(navigation): drop redundant memoization in OnyxTabNavigator
jakubkalinski0 Jun 25, 2026
f4e6256
revert(odometer): drop vestigial tabBar wrapper in DistanceRequestSta…
jakubkalinski0 Jun 25, 2026
6257c1f
refactor(odometer): merge resync helpers into OdometerUtils and renam…
jakubkalinski0 Jun 25, 2026
b483157
fix(odometer): defer blob verification until draft re-mint lands
jakubkalinski0 Jun 25, 2026
375d63b
test(odometer): return a resolved promise from hydrate mock
jakubkalinski0 Jun 25, 2026
5c1e679
Merge remote-tracking branch 'origin/main' into jakubkalinski0/Odomet…
Julesssss Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,7 @@
"reimbursability",
"reimbursementid",
"reimbursible",
"reminted",
"remotedesktop",
"remotesync",
"removeHiddenElems",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type {LocaleContextProps} from '@components/LocaleContextProvider';

/**
* Single source of truth for the "Discard changes?" modal content, so every caller (nav-away and tab-switch)
* renders an identical modal instead of drifting apart. Behavioral flags like the web-only
* `shouldIgnoreBackHandlerDuringTransition` are added per caller, not here
*/
function getDiscardChangesModalConfig(translate: LocaleContextProps['translate']) {
return {
title: translate('discardChangesConfirmation.title'),
prompt: translate('discardChangesConfirmation.body'),
danger: true,
confirmText: translate('discardChangesConfirmation.confirmText'),
cancelText: translate('common.cancel'),
};
}

export default getDiscardChangesModalConfig;
25 changes: 16 additions & 9 deletions src/hooks/useDiscardChangesConfirmation/index.native.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import type {NavigationAction} from '@react-navigation/native';
import {useFocusEffect, useIsFocused, usePreventRemove} from '@react-navigation/native';
import {useFocusEffect, useIsFocused, usePreventRemove, useRoute} from '@react-navigation/native';
import {useRef} from 'react';
import {BackHandler} from 'react-native';
import {ModalActions} from '@components/Modal/Global/ModalContext';
import useConfirmModal from '@hooks/useConfirmModal';
import useLocalize from '@hooks/useLocalize';
import Log from '@libs/Log';
import navigationRef from '@libs/Navigation/navigationRef';
import {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext';
import getDiscardChangesModalConfig from './getDiscardChangesModalConfig';
import type {DiscardChangesConfirmation} from './types';
import type UseDiscardChangesConfirmationOptions from './types';

function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions): DiscardChangesConfirmation {
function useDiscardChangesConfirmation({
getHasUnsavedChanges,
onCancel,
onVisibilityChange,
onConfirm,
onTabSwitchDiscard,
}: UseDiscardChangesConfirmationOptions): DiscardChangesConfirmation {
const route = useRoute();
const {translate} = useLocalize();
const {showConfirmModal} = useConfirmModal();
const blockedNavigationAction = useRef<NavigationAction | undefined>(undefined);
Expand All @@ -25,17 +34,15 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi
});
const hasUnsavedChanges = () => isFocused && !isSavingRef.current && getHasUnsavedChanges();

// Also guard tab switches when this screen is an OnyxTabNavigator tab.
// Self-disables outside a tab navigator or without an onTabSwitchDiscard handler
useRegisterTabSwitchGuard(route.name, getHasUnsavedChanges, onTabSwitchDiscard, onCancel);

const showDiscardModal = (blockedAction?: NavigationAction) => {
blockedNavigationAction.current = blockedAction;
isDiscardModalOpen.current = true;
onVisibilityChange?.(true);
showConfirmModal({
title: translate('discardChangesConfirmation.title'),
prompt: translate('discardChangesConfirmation.body'),
danger: true,
confirmText: translate('discardChangesConfirmation.confirmText'),
cancelText: translate('common.cancel'),
}).then((result) => {
showConfirmModal(getDiscardChangesModalConfig(translate)).then((result) => {
isDiscardModalOpen.current = false;
onVisibilityChange?.(false);
if (result.action !== ModalActions.CONFIRM) {
Expand Down
23 changes: 16 additions & 7 deletions src/hooks/useDiscardChangesConfirmation/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {NavigationAction} from '@react-navigation/native';
import {useFocusEffect, useIsFocused} from '@react-navigation/native';
import {useFocusEffect, useIsFocused, useRoute} from '@react-navigation/native';
import {useEffect, useRef} from 'react';
import {ModalActions} from '@components/Modal/Global/ModalContext';
import useBeforeRemove from '@hooks/useBeforeRemove';
Expand All @@ -8,12 +8,25 @@ import useLocalize from '@hooks/useLocalize';
import Log from '@libs/Log';
import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue';
import navigationRef from '@libs/Navigation/navigationRef';
import {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext';
import getDiscardChangesModalConfig from './getDiscardChangesModalConfig';
import type {DiscardChangesConfirmation} from './types';
import type UseDiscardChangesConfirmationOptions from './types';

function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions): DiscardChangesConfirmation {
function useDiscardChangesConfirmation({
getHasUnsavedChanges,
onCancel,
onVisibilityChange,
onConfirm,
onTabSwitchDiscard,
}: UseDiscardChangesConfirmationOptions): DiscardChangesConfirmation {
const route = useRoute();
const {translate} = useLocalize();
const {showConfirmModal, closeModal} = useConfirmModal();

// Also guard tab switches when this screen is an OnyxTabNavigator tab.
// Self-disables outside a tab navigator or without an onTabSwitchDiscard handler
useRegisterTabSwitchGuard(route.name, getHasUnsavedChanges, onTabSwitchDiscard, onCancel);
const blockedNavigationAction = useRef<NavigationAction>(undefined);
const shouldNavigateBack = useRef(false);
const isDiscardModalOpen = useRef(false);
Expand Down Expand Up @@ -42,11 +55,7 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi
isDiscardModalOpen.current = true;
onVisibilityChange?.(true);
showConfirmModal({
title: translate('discardChangesConfirmation.title'),
prompt: translate('discardChangesConfirmation.body'),
danger: true,
confirmText: translate('discardChangesConfirmation.confirmText'),
cancelText: translate('common.cancel'),
...getDiscardChangesModalConfig(translate),
shouldIgnoreBackHandlerDuringTransition: true,
shouldHandleNavigationBack: false,
}).then((result) => {
Expand Down
6 changes: 6 additions & 0 deletions src/hooks/useDiscardChangesConfirmation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ type UseDiscardChangesConfirmationOptions = {
onCancel?: () => void;
onVisibilityChange?: (visible: boolean) => void;
onConfirm?: () => void | Promise<void>;

/**
* Discard action for confirming a tab switch. Provide it to guard tab switches inside an `OnyxTabNavigator`.
* Can differ from `onConfirm` (nav-away)
*/
onTabSwitchDiscard?: () => void | Promise<void>;
};

type DiscardChangesConfirmation = {
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useOdometerReceiptStitcher/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import {useEffect, useReducer, useRef} from 'react';
import useLocalize from '@hooks/useLocalize';
import useRestartOnOdometerImagesFailure from '@hooks/useRestartOnOdometerImagesFailure';
import Log from '@libs/Log';
import {getOdometerImageUri} from '@libs/OdometerImageUtils';
import {deriveOdometerReceipt, stitchTask} from '@libs/OdometerReceipt';
import {getOdometerImageUri} from '@libs/OdometerUtils';
import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt';
import type {FileObject} from '@src/types/utils/Attachment';
import type {OdometerReceiptState, UseOdometerReceiptStitcherArgs, UseOdometerReceiptStitcherResult} from './types';
Expand Down
17 changes: 12 additions & 5 deletions src/hooks/useRestartOnOdometerImagesFailure/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import useOnyx from '@hooks/useOnyx';
import {checkIfLocalFileIsAccessible} from '@libs/actions/IOU/Receipt';
import clearOdometerDraftTransactionState, {hydrateOdometerDraftIntoTransaction} from '@libs/actions/OdometerTransactionUtils';
import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils';
import {getOdometerImageUri} from '@libs/OdometerImageUtils';
import {getOdometerImageUri} from '@libs/OdometerUtils';
import type {IOUType} from '@src/CONST';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -103,13 +103,20 @@ const useRestartOnOdometerImagesFailure = (
// Rehydrate over the dead URLs when a draft exists — clearing first races the destination's
// auto-hydrator and ends up dropping the wrong URL.
if (odometerDraft) {
// Tell the backup hook not to revert on unmount, then re-mint the images from the draft. Only flip
// verification (and navigate) AFTER the merge lands, else the readings hook snapshots its baseline from
// the stale dead-blob image and a later swap to the re-minted image reads as a phantom "Discard changes?".
onBackupHandled?.({shouldResetLocalState: false});
hydrateOdometerDraftIntoTransaction(transaction.transactionID, odometerDraft, transaction.comment);
} else {
onBackupHandled?.({shouldResetLocalState: true});
clearOdometerDraftTransactionState(transaction);
hydrateOdometerDraftIntoTransaction(transaction.transactionID, odometerDraft, transaction.comment).then(() => {
setAsyncVerificationPassed(true);
navigateToStartMoneyRequestStep(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, iouType, transaction.transactionID, reportID, CONST.IOU.ACTION.CREATE, backToReport);
});
return;
}

onBackupHandled?.({shouldResetLocalState: true});
clearOdometerDraftTransactionState(transaction);

navigateToStartMoneyRequestStep(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, iouType, transaction.transactionID, reportID, CONST.IOU.ACTION.CREATE, backToReport);
});
}, [draftTransactionsMetadata, transaction, iouType, reportID, backToReport, onBackupHandled, odometerDraft, odometerDraftStatus]);
Expand Down
Loading
Loading