Normalize odometer input to reject non-numeric characters#84322
Normalize odometer input to reject non-numeric characters#84322
Conversation
The odometer input handlers (handleStartReadingChange and handleEndReadingChange) were storing raw input text in state after validation. The isOdometerInputValid function validates the normalized text (with non-numeric characters stripped) but the raw text was stored, allowing letters and symbols to appear in the input field on web/desktop. This normalizes the text via normalizeOdometerText before calling setStartReading/setEndReading so non-numeric characters are stripped at keystroke time rather than only at submit time. Co-authored-by: Chavda Sachin <ChavdaSachin@users.noreply.github.com>
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
|
@MelvinBot the current implementation throws cursor to the end of the input text when user inputs an invalid character. |
…om odometer input Track selection state for both start and end reading inputs and adjust cursor position when normalizeOdometerText strips characters, following the same pattern used by RoomNameInput. Co-authored-by: Chavda Sachin <ChavdaSachin@users.noreply.github.com>
|
Fixed the cursor jump issue. The problem was that when The fix tracks the cursor position via |
|
@MelvinBot similar issue can still be observed for if cursor is more than 1 character away from the end and user inputs a decimal point. |
When isOdometerInputValid rejects input (e.g. decimal point creating >1 decimal digit), force a selection state update with a new object reference so React re-renders and re-applies the selection prop, preventing the cursor from jumping to the end. Co-authored-by: Chavda Sachin <ChavdaSachin@users.noreply.github.com>
|
Fixed. The decimal point issue was a different code path — when The fix forces a selection state update with a new object reference ( |
|
@MelvinBot The code looks a little bit messy now do you have any cleaner approach? |
|
Here are two cleaner approaches, listed from simplest to most structured: Approach 1: Reject instead of strip (eliminates cursor management entirely)Instead of normalizing the input (stripping invalid chars) and then managing cursor position, simply reject the entire change when the raw text contains invalid characters: const handleStartReadingChange = (text: string) => {
const normalized = DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit);
// Reject input that contains any non-numeric characters
if (normalized !== text) {
return;
}
if (!isOdometerInputValid(text, startReading)) {
return;
}
setStartReading(normalized);
startReadingRef.current = normalized;
if (formError) {
setFormError('');
}
};Pros:
Cons:
Trade-off: If pasting mixed content is a requirement, this won't work. If not, this is the simplest possible fix. Approach 2: Shared helper to DRY the handlers (keeps strip + cursor management)If we need to preserve the "strip invalid chars" behavior (e.g., for paste), extract the duplicated logic into a single shared function: const handleOdometerChange = (
text: string,
reading: string,
setReading: (val: string) => void,
readingRef: React.MutableRefObject<string>,
selection: {start: number; end: number},
setSelectionState: React.Dispatch<React.SetStateAction<{start: number; end: number}>>,
) => {
if (!isOdometerInputValid(text, reading)) {
setSelectionState((prev) => ({...prev}));
return;
}
const normalized = DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit);
if (normalized !== text) {
const offset = normalized.length - reading.length;
const pos = Math.max(0, selection.end + offset);
setSelectionState({start: pos, end: pos});
}
setReading(normalized);
readingRef.current = normalized;
if (formError) {
setFormError('');
}
};
// One-liner wrappers:
const handleStartReadingChange = (text: string) =>
handleOdometerChange(text, startReading, setStartReading, startReadingRef, startSelection, setStartSelection);
const handleEndReadingChange = (text: string) =>
handleOdometerChange(text, endReading, setEndReading, endReadingRef, endSelection, setEndSelection);Pros:
Cons:
My recommendation would be Approach 1 if paste-stripping isn't a requirement (since mobile keyboards already prevent invalid input, and on web rejecting the input is a reasonable behavior). Otherwise Approach 2 keeps the current behavior but eliminates the duplication. Let me know which direction you'd prefer and I'll implement it. |
|
@MelvinBot Approach 1 looks cleaner but it still has the cursor jumping issue. |
- Switch to Approach 1 (reject invalid input instead of stripping) for cleaner logic with no normalization-based cursor math - Use imperative cursor restoration via requestAnimationFrame + the input ref's setSelectionRange/setSelection, avoiding race conditions with onSelectionChange that caused the selection-state approach to fail - Track cursor position in refs instead of state to avoid timing issues - Add max 7-digit integer part validation to prevent infinite zeroes Co-authored-by: Chavda Sachin <ChavdaSachin@users.noreply.github.com>
|
Pushed fixes for both issues: Cursor jumping: The previous selection-state approach failed because
Infinite zeroes: Added a check that limits the integer part to 7 digits (matching the max value of 9,999,999.9). This prevents typing more than 7 digits before the decimal point, which caps zeroes at the same limit as any other digit. |
Add type assertions for setSelectionRange and setSelection calls after runtime type guards to satisfy @typescript-eslint/no-unsafe-call. Co-authored-by: Chavda Sachin <ChavdaSachin@users.noreply.github.com>
|
Fixed the failing The remaining 3 warnings ( |
|
@MelvinBot lets fall back to commit - d1d1038 , I confirmed other inputs in the app behaves similar. |
|
Reverted the 4 commits after Reverted commits:
|
|
@MelvinBot mark it as ready for review |
|
@ChavdaSachin 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] |
|
Done — PR has been marked as ready for review. |
| } | ||
| setStartReading(text); | ||
| startReadingRef.current = text; | ||
| const normalized = DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit); |
There was a problem hiding this comment.
❌ CONSISTENCY-3 (docs)
DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit) is already called inside isOdometerInputValid (line 286) which runs immediately before this line. The normalized result is computed, used for validation, then discarded -- only to be recomputed identically here. The same duplication exists in handleEndReadingChange (line 331).
Consider refactoring isOdometerInputValid to return the normalized text alongside the validity result, so callers can reuse it:
const validateAndNormalizeOdometer = (text: string, previousText: string): {isValid: boolean; normalized: string} => {
const normalized = DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit);
if (!text) {
return {isValid: true, normalized};
}
const parts = normalized.split('.');
// ... existing validation logic using `normalized` instead of `stripped` ...
return {isValid: true, normalized};
};
// In handleStartReadingChange:
const {isValid, normalized} = validateAndNormalizeOdometer(text, startReading);
if (!isValid) {
return;
}
setStartReading(normalized);
startReadingRef.current = normalized;This eliminates the redundant normalizeOdometerText call in both handlers.
Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.
There was a problem hiding this comment.
@MelvinBot I think early returning if !isOdometerInputValid(text, startReading) is self sufficient, lets remove the duplicate checks.
There was a problem hiding this comment.
Done. Removed the duplicate normalizeOdometerText calls from both handleStartReadingChange and handleEndReadingChange. The handlers now store text directly after the isOdometerInputValid early return. Normalization still happens downstream where the values are consumed (e.g., totalDistance, navigateToNextPage, handleNext).
| const handleEndReadingChange = (text: string) => { | ||
| if (!isOdometerInputValid(text, endReading)) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
❌ CONSISTENCY-3 (docs)
Same redundant normalizeOdometerText call as in handleStartReadingChange (line 320). See the comment there for the suggested refactor that would eliminate duplication in both handlers.
Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.
|
The failing check ( Failing test: Analysis: This is a session/deep-linking UI test that exceeded its timeout limit. This PR only modifies Evidence: The PR changes one file ( |
|
The failing check is unrelated to this PR. Failing test: Analysis: This is a flaky timeout in a deep linking / session management test. This PR only modifies Evidence: The PR changes a single file ( The other failing check ( Recommendation: Re-run the failed |
|
The PR looks good in its current state. A few observations: What's working well:
Minor note (not blocking):
CI status: The only failing check ( Overall this is a clean, focused fix. I don't see any issues. |
|
The failing check is unrelated to this PR. Failing test: Analysis: This is a UI session/deep-linking test timeout, completely unrelated to the odometer input normalization change in Evidence: This PR only modifies odometer input handling (normalizing text to strip non-numeric characters). The failing test deals with deep link login session persistence, which has no code path overlap with the changed file. The other failing check ( |
…lers The isOdometerInputValid early return is sufficient for input validation. The normalizeOdometerText calls in handleStartReadingChange and handleEndReadingChange were redundant since isOdometerInputValid already normalizes internally. Store the raw text directly, matching how other inputs in the app behave. Normalization still occurs downstream when the values are consumed (totalDistance calculation, navigateToNextPage, handleNext). Co-authored-by: Chavda Sachin <ChavdaSachin@users.noreply.github.com>
|
@MelvinBot please close this PR and create a brand new PR with changes in commit |
|
New clean PR created: #85483 This contains only the normalization change from commit |
Explanation of Change
The odometer input handlers (
handleStartReadingChangeandhandleEndReadingChange) were storing raw input text in state after validation. TheisOdometerInputValidfunction validates the normalized text (with non-numeric characters already stripped vianormalizeOdometerText) but then the raw, un-normalized text was being stored in state viasetStartReading/setEndReading. This meant non-numeric characters (letters, symbols) passed validation and were displayed in the input field on web/desktop, whereinputMode="decimal"only affects the virtual keyboard layout and does not filter typed characters.This change normalizes the input text via
DistanceRequestUtils.normalizeOdometerText()before storing it in state, so non-numeric characters are stripped at keystroke time rather than only at submit time.Fixed Issues
$ #84220
PROPOSAL: #84220 (comment)
Tests
Offline tests
N/A — This is a client-side input filtering change with no network dependency. The odometer input filtering happens entirely in the React component's change handler.
QA Steps
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand 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
Web: Chrome
Tested on web — odometer input fields correctly reject non-numeric characters. Typing "abc123" results in only "123" being displayed. Typing "@#$%" results in an empty field. Valid decimal input like "12345.6" is accepted correctly.
Android: Native
N/A —
inputMode="decimal"already shows numeric keyboard on mobile, preventing non-numeric input.Android: mWeb Chrome
N/A —
inputMode="decimal"already shows numeric keyboard on mobile, preventing non-numeric input.iOS: Native
N/A —
keyboardType="decimal-pad"already shows numeric keyboard on iOS, preventing non-numeric input.iOS: mWeb Safari
N/A —
inputMode="decimal"already shows numeric keyboard on mobile, preventing non-numeric input.MacOS: Chrome / Safari
Same behavior as Web: Chrome — non-numeric characters are stripped at keystroke time.