Skip to content

Fix iOS Track distance screen blink on swipe back - #97020

Open
MobileMage wants to merge 12 commits into
Expensify:mainfrom
MobileMage:MobileMage/fix-94904-ios-discard-blink
Open

Fix iOS Track distance screen blink on swipe back#97020
MobileMage wants to merge 12 commits into
Expensify:mainfrom
MobileMage:MobileMage/fix-94904-ios-discard-blink

Conversation

@MobileMage

@MobileMage MobileMage commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

On iOS, edge-swiping back on an IOU step screen (Track distance, amount, hours, manual distance) flashes the screen before it dismisses. The shared discard-changes hook armed react-navigation's removal guard with a hard-coded usePreventRemove(true, ...), which iOS maps to preventNativeDismiss = true. On a clean screen with nothing to discard, the native swipe still gets cancelled and snaps back before JS re-dispatches the pop, and that snap-back is the blink.

This PR arms the guard only when the screen has unsaved changes. useDiscardChangesConfirmation/index.native.ts computes isFocused && getHasUnsavedChanges() during render and passes that to usePreventRemove. A clean screen dismisses in one native animation with no blink, and a dirty screen still cancels the swipe and shows the discard modal. The beforeRemove callback re-checks dirtiness and adds the save suppression, which is what lets an intentional save through: isSavingRef is a ref, so it cannot be read during render.

Reading dirtiness during render only works if every caller derives it from values that change with a re-render. Each step that used to read its input through an imperative ref at navigation time now mirrors that input into state and diffs it against the committed value on the transaction:

  • IOURequestStepAmount mirrors typedAmount, fed by MoneyRequestAmountForm's new onAmountChange. It stays undefined until the form reports a change, so a prefilled amount starts clean. The +/- sign flip reports as well, since it bypasses the input's own change handler.
  • IOURequestStepHours mirrors typedCount and re-seeds it from committedCount in the same effect that pushes the value into the input.
  • IOURequestStepDistanceManual mirrors typedDistance the same way. The re-seed carries weight here because the transaction can hydrate after the screen mounts, which would otherwise leave an empty mirror against a loaded distance and prompt on a screen the user never touched.
  • IOURequestStepDistance mirrors the Manual tab's value in manualDistanceValue, undefined until that tab reports one, so a map expense the user never switched to Manual is not compared against an empty field.

The web hook keeps its behavior: it reads dirtiness inside useBeforeRemove at navigation time. A hardware back goes through this hook's own BackHandler listener, which calls hasUnsavedChanges() directly rather than through preventRemove, and header-back taps flow through the same re-armed guard.

Fixed Issues

$ #94904
PROPOSAL: #94904 (comment)

Tests

  1. Open the Expensify app.
  2. Tap the FAB button > Track distance.
  3. Tap Odometer.
  4. Swipe back.
  5. Verify the Inbox or previous screen is displayed, and the "Track distance" screen does not appear or blink.
  6. Repeat, but enter a value on the step before swiping back, and verify the discard-changes modal appears.
  7. Repeat steps 2-4, type a digit into a reading, delete it so the reading matches its original value, then swipe back once. Verify the screen dismisses on that first swipe and no discard modal appears.
  8. Edit a map-distance expense, open the Manual tab, type a distance, swipe back. Verify the discard modal appears.
  9. Tap the FAB > Create expense > Manual. Tap only the +/- key without typing a digit, then swipe back. Verify the discard modal appears.
  10. In Workspace settings > More features, turn on Time. Tap the FAB > Create expense > Time, type a value, swipe back. Verify the discard modal appears. Repeat without typing a value and verify the screen dismisses with no modal and no blink.
  11. Open an existing distance expense, tap the distance amount to edit it, wait for the field to show the saved distance, then swipe back without typing. Verify the screen dismisses with no discard modal.
  • Verify that no errors appear in the JS console

Offline tests

No offline-specific behavior. The change is client-side navigation only.

  • Verify that no errors appear in the JS console

QA Steps

Same as Tests.

  • 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 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 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)
  • 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)
  • 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.
  • 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
97020-android-native-small.mp4
Android: mWeb Chrome
97020-android-mweb-small.mp4
iOS: Native
Cap.2026-08-15.at.16.38.59.mp4
iOS: mWeb Safari
97020-ios-mweb.mp4
MacOS: Chrome / Safari
97020-chrome-web-small.mp4

usePreventRemove was hard-coded to true, so iOS set preventNativeDismiss
even with nothing to discard: the edge-swipe was cancelled and snapped
back before JS popped the screen. Drive the guard from a shouldPreventRemove
state that only reflects real unsaved changes, recomputed in a callback/effect
(never reading refs during render, so React Compiler still compiles the hook).
Ref-based input screens call recheckUnsavedChanges after each keystroke so
the guard re-arms. Fixes Expensify#94904.
@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.

Files with missing lines Coverage Δ
...ooks/useDiscardChangesConfirmation/index.native.ts 100.00% <100.00%> (ø)
...ages/iou/request/step/DistanceManualTabContent.tsx 100.00% <ø> (ø)
...iou/request/step/DynamicIOURequestStepDistance.tsx 85.43% <100.00%> (+3.54%) ⬆️
...quest/step/DynamicIOURequestStepDistanceManual.tsx 80.29% <100.00%> (+78.06%) ⬆️
...rc/pages/iou/request/step/IOURequestStepAmount.tsx 92.50% <100.00%> (+1.82%) ⬆️
...uestStepDistance/hooks/useOdometerReadingsState.ts 100.00% <100.00%> (ø)
...ou/request/step/IOURequestStepDistanceOdometer.tsx 83.54% <ø> (ø)
src/pages/iou/request/step/IOURequestStepHours.tsx 83.78% <100.00%> (+2.45%) ⬆️
src/pages/iou/MoneyRequestAmountForm.tsx 80.43% <0.00%> (-3.48%) ⬇️
... and 209 files with indirect coverage changes

The preventRemove state could read one input event behind (or never arm)
for screens whose typed value lives in a child input ref:

- Defer the recompute past the commit so it reads the child's settled
  state instead of the previous render's value (single digit or paste
  then back would have dismissed with no prompt).
- Re-evaluate after every commit instead of only when the memoized
  dirtiness callback changes identity, which covers screens like the
  odometer step whose refs move together with state the compiler
  does not track through the callback.
- Wire recheckUnsavedChanges into the distance step's manual tab, which
  never re-renders on typing.
- Notify the parent from the amount sign flip, which bypassed the
  input's change handler entirely.
- Replace the web hook's counter reducer with a stable no-op so amount,
  hours, and manual-distance screens stop re-rendering per keystroke.
- Pin the recheck arming behavior in the native hook tests.
@MobileMage
MobileMage marked this pull request as ready for review July 29, 2026 22:27
@MobileMage
MobileMage requested review from a team as code owners July 29, 2026 22:27
@melvin-bot
melvin-bot Bot requested a review from nyomanjyotisa July 29, 2026 22:27
@melvin-bot

melvin-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

@nyomanjyotisa Please 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]

@melvin-bot
melvin-bot Bot requested review from heyjennahay and removed request for a team July 29, 2026 22:27
@MobileMage

Copy link
Copy Markdown
Contributor Author

@nyomanjyotisa @heyjennahay ready for review. The issue only reproduces on iOS, so I recorded iOS native and mWeb Safari. Happy to add Android and macOS if you want them.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 938fec7d22

ℹ️ 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".

Comment on lines +47 to +49
setTimeout(() => {
setShouldPreventRemove(isFocused && !isSavingRef.current && getHasUnsavedChanges());
}, 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Arm native removal guard before deferring the recheck

When a clean native screen becomes dirty, this leaves shouldPreventRemove false until the zero-delay timer fires and the follow-up render propagates to usePreventRemove. During that window an iOS edge-swipe/header pop can start with preventNativeDismiss still disabled and dismiss the dirty amount/distance/hours screen without showing the discard modal; the new tests don't catch this because they call the mocked beforeRemove callback even when the flag is false. The guard needs to be armed synchronously (or otherwise conservatively) before any deferred ref read can refine it.

Useful? React with 👍 / 👎.

// `usePreventRemove` reads this during render, so the signal must be state, never a ref (React Compiler)
const [shouldPreventRemove, setShouldPreventRemove] = useState(false);
// Deferred past the commit so ref-backed inputs are read after their child state settles, not one event behind
const recheckUnsavedChanges = useCallback(() => {

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.

❌ CLEAN-REACT-PATTERNS-0 (docs)

React Compiler is enabled in this codebase and automatically memoizes closures based on their captured variables. If this hook compiles with React Compiler (which the PR states is the case), the manual useCallback wrapping recheckUnsavedChanges is redundant — it adds a dependency array to maintain and interferes with the compiler's own caching.

Remove the useCallback and let the compiler memoize the closure:

const recheckUnsavedChanges = () => {
    setTimeout(() => {
        setShouldPreventRemove(isFocused && !isSavingRef.current && getHasUnsavedChanges());
    }, 0);
};

(If check-compiler.sh reports "Failed to compile" for this file, disregard — the rule does not apply when the file cannot be compiled.)


Reviewed at: 938fec7 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

}, 0);
}, [isFocused, getHasUnsavedChanges]);
// Runs every commit since dirtiness often lives in refs; screens that never re-render on input call `recheckUnsavedChanges` themselves
useEffect(recheckUnsavedChanges);

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.

❌ PERF-12 (docs)

useEffect(recheckUnsavedChanges) runs on every commit and each run schedules a setTimeout(..., 0) that is never cleared. If the component unmounts (e.g. the screen is dismissed) with a timer still pending, the callback fires after unmount and calls setShouldPreventRemove on an unmounted component — an uncleaned resource / potential leak and a React state-update-after-unmount warning. A setTimeout(…, 0) scheduled on the final pre-unmount commit is not guaranteed to run before unmount.

Track the timer id and clear it in the effect's cleanup:

const recheckUnsavedChanges = useCallback(() => {
    // return the id so the caller can clear it
}, [isFocused, getHasUnsavedChanges]);

useEffect(() => {
    const id = setTimeout(() => {
        setShouldPreventRemove(isFocused && !isSavingRef.current && getHasUnsavedChanges());
    }, 0);
    return () => clearTimeout(id);
});

Note that the manual recheckUnsavedChanges() calls from the input change handlers also leak their timers and should be cleaned up (e.g. via a shared ref that is cleared on unmount).


Reviewed at: 938fec7 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

An explicit recheck now sets shouldPreventRemove immediately and lets the
deferred read relax it, so a swipe starting before the timer fires cannot
dismiss a dirty screen unguarded. The every-commit effect stays a plain
read to keep preventNativeDismiss off clean screens.

@nyomanjyotisa nyomanjyotisa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Swipe-back correctly shows the discard modal for dirty Odometer and Hours inputs. After reverting to the baseline, however, an immediate swipe is swallowed and only works after the deferred recheck.

Could you please follow the selected direction by deriving dirtiness from current values and baselines instead of a ref recheck, while retaining the callback safety check and covering clean, dirty, reverted, and prefilled cases?

Please also add an Hours test step, recordings for Manual distance, Amount +/-, and Hours, plus the missing Android native, Android mWeb, and macOS recordings.

Each step screen now mirrors its input value in state and compares it with the
committed baseline, so the hook can read dirtiness during render. That removes
the ref recheck, the deferred timer, and the window where reverting to the
baseline left the guard armed and swallowed an immediate swipe.

The in-callback check still applies the save suppression, and the amount value
is reported signed the same way the form composes it.
@nyomanjyotisa

Copy link
Copy Markdown
Member

Please let me know once everything in my previous review has been addressed.

@nyomanjyotisa

Copy link
Copy Markdown
Member

@MobileMage bump

@MobileMage

Copy link
Copy Markdown
Contributor Author

@nyomanjyotisa I've made the changes and updated the videos

@nyomanjyotisa nyomanjyotisa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please also update the Explanation of Change to match the latest implementation.

@@ -159,11 +159,12 @@ function IOURequestStepDistanceManual({
const distanceInMeters = getDistanceInMeters(transaction, transaction?.comment?.customUnit?.distanceUnit ? transaction.comment.customUnit.distanceUnit : unit);
const distance = typeof transaction?.comment?.customUnit?.quantity === 'number' ? roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit)) : undefined;

const committedDistance = distance?.toString() ?? '';
// Mirrors the input so dirtiness compares the current value against the baseline instead of reading a ref
const [typedDistance, setTypedDistance] = useState(committedDistance);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This stays stale if a prefilled transaction loads after mount, so an untouched screen shows the discard prompt. Please sync it and add a late-hydration test.

The mount-time mirror read committedDistance before Onyx had the transaction,
so it held '' while the input already showed the loaded distance. An untouched
edit screen then read as dirty and prompted on back.

The sync effect now seeds the mirror alongside the imperative input update and
keys on committedDistance, matching IOURequestStepHours. The new test mounts
with only the report in Onyx, merges the transaction after mount, and asserts
the guard stays disarmed.
@MobileMage

Copy link
Copy Markdown
Contributor Author

Both are fixed and pushed in 94c2d41.

IOURequestStepDistanceManual.tsx:164 seeded the mirror from committedDistance before Onyx hydrated, so it held '' while the input already showed the loaded distance, and an untouched edit screen prompted on swipe back. I moved the re-seed into the existing sync effect and keyed it on committedDistance, matching IOURequestStepHours. tests/ui/IOURequestStepDistanceManualTest.tsx mounts with only the report in Onyx, merges the transaction after mount, and asserts the guard stays disarmed. Reverting the one line turns it red.

I also rewrote the Explanation of Change. It described recheckUnsavedChanges, which I dropped in fc1196d when I moved to the render-time isFocused && getHasUnsavedChanges() read plus per-screen state mirrors. Test step 11 covers the hydration case, and the videos placeholder is gone now that all five recordings are attached.

@nyomanjyotisa

Copy link
Copy Markdown
Member

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible 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 checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (see Reviewing the code)
    • 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 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)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • 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 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.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this 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.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

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


// Callers derive dirtiness from current values and baselines, so this is safe to read during render.
// The save suppression stays out of it because `isSavingRef` is a ref: the callback below applies that.
const shouldPreventRemove = isFocused && getHasUnsavedChanges();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The Odometer guard still misses dirty input on iOS native. Entering 7 and edge-swiping back dismisses the screen without the discard modal. The Odometer callback reads refs, so React Compiler keeps this render-time result stale. Please derive dirtiness from startReading and endReading, add native clean → dirty → reverted coverage, and update the iOS recording.

@JakubKorytko

Copy link
Copy Markdown
Member

Hi, I encountered this on the QAB "Create expense" flow too (amount step, 1:1 and to WS) - same blink on swipe-back. Bisected it down to useDiscardChangesConfirmation's usePreventRemove(true, ...) on my end, matches your root cause exactly. May you take a look if QAB create expense is fixed by this as well? 🙏

@nyomanjyotisa

Copy link
Copy Markdown
Member

@JakubKorytko Yes, I tested it and confirmed that this PR fixes the QAB Create expense flow as well.

@nyomanjyotisa

Copy link
Copy Markdown
Member

@MobileMage bump

@MobileMage

Copy link
Copy Markdown
Contributor Author

@nyomanjyotisa Fixed and pushed in 99ddc4d.

IOURequestStepDistanceOdometer.tsx:527 diffed the readings through startReadingRef/endReadingRef, so nothing that closure captured changed on a keystroke. React Compiler handed the hook the same closure, the hook reused its memoized isFocused && getHasUnsavedChanges(), and the flag iOS maps to preventNativeDismiss stayed false through the 7. useOdometerReadingsState now publishes a readingsBaseline state mirror beside the baseline refs, the screen diffs the startReading/endReading state against it, and isGuardActive reads readingsBaseline.hasInitialized. An external resync slides the mirror along with the refs, so draft hydration still doesn't read as an edit.

tests/ui/IOURequestStepDistanceOdometerDiscardGuardTest.tsx renders Track distance > Odometer with the native hook pinned and asserts the flag usePreventRemove receives, not what the callback returns when asked. Clean, dirty, reverted, once against an empty baseline and once against a seeded one. Both cases go red on the ref read.

iOS recording next.

@nyomanjyotisa

Copy link
Copy Markdown
Member

Waiting for the iOS recording. Please also resolve the conflicts, @MobileMage.

@MobileMage

Copy link
Copy Markdown
Contributor Author

@nyomanjyotisa Merged main, conflicts resolved, CI is green, and the iOS recording is updated.

99ddc4d fixes the Odometer guard: useOdometerReadingsState now publishes a readingsBaseline
state mirror, so the screen diffs startReading/endReading against state instead of refs and
React Compiler can't serve a stale flag.

Clean -> dirty -> reverted coverage is in IOURequestStepDistanceOdometerDiscardGuardTest.tsx, and
the late-hydration case you flagged is covered in IOURequestStepDistanceManualTest.tsx.

@nyomanjyotisa nyomanjyotisa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please also update the Explanation of Change to include the Odometer readingsBaseline state mirror.

@@ -0,0 +1,241 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you add a brief justification for these file-wide ESLint disables? The same applies to IOURequestStepDistanceOdometerDiscardGuardTest.tsx.

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 just took them out entirely, most of it came from one bare require('react') making everything downstream any, so typing that plus the native hook cleared nearly all of it.

There's one assertion left for the route params the screen reads at runtime but the type calls never.

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.

3 participants