diff --git a/src/components/ValidateCodeCountdown/index.tsx b/src/components/ValidateCodeCountdown/index.tsx index 09d1496426b2..33e208f6ddce 100644 --- a/src/components/ValidateCodeCountdown/index.tsx +++ b/src/components/ValidateCodeCountdown/index.tsx @@ -14,14 +14,20 @@ import type {ValidateCodeCountdownProps} from './types'; function ValidateCodeCountdown({onCountdownFinish, requestedAt, ref}: ValidateCodeCountdownProps) { const {translate} = useLocalize(); - // Seed from the time the code was actually requested so a reload mid-countdown resumes at the correct value instead of restarting at the full delay. + const [startedAt, setStartedAt] = useState(() => Date.now()); + const anchor = requestedAt ?? startedAt; + + // Seeding from the request time lets a reload mid-countdown resume instead of restarting at the full delay. const [timeRemaining, setTimeRemaining] = useState( - () => DateUtils.getRemainingSecondsInWindow(requestedAt, CONST.REQUEST_CODE_DELAY * CONST.MILLISECONDS_PER_SECOND) || CONST.REQUEST_CODE_DELAY, + () => DateUtils.getRemainingSecondsInWindow(anchor, CONST.REQUEST_CODE_DELAY * CONST.MILLISECONDS_PER_SECOND) || CONST.REQUEST_CODE_DELAY, ); const timerRef = useRef(undefined); useImperativeHandle(ref, () => ({ - resetCountdown: () => setTimeRemaining(CONST.REQUEST_CODE_DELAY), + resetCountdown: () => { + setStartedAt(Date.now()); + setTimeRemaining(CONST.REQUEST_CODE_DELAY); + }, })); useEffect(() => { @@ -30,21 +36,18 @@ function ValidateCodeCountdown({onCountdownFinish, requestedAt, ref}: ValidateCo return; } - // When anchored to `requestedAt`, align the next tick to the wall-clock second boundary so every tab/reload flips the - // displayed second at the same instant instead of drifting by each tab's own mount offset. Without an anchor (the - // `hasValidateCodeBeenSent` flows) there is nothing to align to, so fall back to a fixed 1s cadence. - const msUntilNextTick = requestedAt ? CONST.MILLISECONDS_PER_SECOND - ((Date.now() - requestedAt) % CONST.MILLISECONDS_PER_SECOND) : CONST.MILLISECONDS_PER_SECOND; + // Align to the anchor's second boundary so every tab and reload flips the same second at the same instant. + const msUntilNextTick = CONST.MILLISECONDS_PER_SECOND - ((Date.now() - anchor) % CONST.MILLISECONDS_PER_SECOND); timerRef.current = setTimeout(() => { - // With an anchor, re-derive from the wall clock so the countdown self-corrects against setTimeout drift, - // background-tab throttling, and cross-tab phase differences. Without one, keep the simple decrement. - setTimeRemaining((prev) => (requestedAt ? DateUtils.getRemainingSecondsInWindow(requestedAt, CONST.REQUEST_CODE_DELAY * CONST.MILLISECONDS_PER_SECOND) : prev - 1)); + // Recompute instead of decrementing: a throttled tab or a suspended app delivers fewer callbacks than seconds. + setTimeRemaining(DateUtils.getRemainingSecondsInWindow(anchor, CONST.REQUEST_CODE_DELAY * CONST.MILLISECONDS_PER_SECOND)); }, msUntilNextTick); return () => { clearTimeout(timerRef.current); }; - }, [onCountdownFinish, timeRemaining, requestedAt]); + }, [onCountdownFinish, timeRemaining, anchor]); // Announce countdown start/reset/expiration for screen readers. // We check timeRemaining === 1 (not 0) because the component unmounts immediately at 0s, so the expired announcement wouldn't be spoken. diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index fd48f679418b..4248c25c33f4 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -422,7 +422,10 @@ function getRemainingSecondsInWindow(requestedAt: number | undefined, windowMs: if (!requestedAt) { return 0; } - return Math.max(0, Math.ceil((windowMs - (Date.now() - requestedAt)) / CONST.MILLISECONDS_PER_SECOND)); + const remainingSeconds = Math.ceil((windowMs - (Date.now() - requestedAt)) / CONST.MILLISECONDS_PER_SECOND); + + // A backward clock correction leaves `requestedAt` in the future, which would otherwise report more than the window. + return Math.min(windowMs / CONST.MILLISECONDS_PER_SECOND, Math.max(0, remainingSeconds)); } /** diff --git a/tests/ui/ValidateCodeCountdownTest.tsx b/tests/ui/ValidateCodeCountdownTest.tsx new file mode 100644 index 000000000000..328a3dca11c5 --- /dev/null +++ b/tests/ui/ValidateCodeCountdownTest.tsx @@ -0,0 +1,102 @@ +import {act, render, screen} from '@testing-library/react-native'; + +import ValidateCodeCountdown from '@components/ValidateCodeCountdown'; +import type {ValidateCodeCountdownHandle} from '@components/ValidateCodeCountdown/types'; + +import CONST from '@src/CONST'; + +import type ReactNative from 'react-native'; + +import React, {createRef} from 'react'; + +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string, params?: {timeRemaining?: string}) => params?.timeRemaining ?? key, + })), +); +jest.mock('@hooks/useAccessibilityAnnouncement', () => jest.fn()); +jest.mock('@components/RenderHTML', () => { + const ReactMock = jest.requireActual('react'); + const {Text} = jest.requireActual('react-native'); + + return ({html}: {html: string}) => ReactMock.createElement(Text, null, html.replaceAll(/<[^>]*>/g, '')); +}); + +const BASE_TIME = new Date('2026-08-21T10:00:00.000Z').valueOf(); + +// One act per tick so React commits the effect that schedules the next one. +function tickSeconds(seconds: number) { + for (let i = 0; i < seconds; i++) { + act(() => jest.advanceTimersByTime(CONST.MILLISECONDS_PER_SECOND)); + } +} + +// Clock advances with no callback delivered, the way a throttled tab or a backgrounded app behaves. +function suspendFor(milliseconds: number) { + act(() => { + jest.setSystemTime(Date.now() + milliseconds); + jest.advanceTimersByTime(CONST.MILLISECONDS_PER_SECOND); + }); +} + +function renderCountdown(onCountdownFinish: () => void = jest.fn(), requestedAt?: number) { + const ref = createRef(); + render( + , + ); + return ref; +} + +describe('ValidateCodeCountdown', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(BASE_TIME); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('reports the time that actually elapsed after the timer was starved of callbacks', () => { + renderCountdown(); + expect(screen.getByText('00:30')).toBeOnTheScreen(); + + tickSeconds(3); + expect(screen.getByText('00:27')).toBeOnTheScreen(); + + // 14s of the 30s window are gone once the single resume callback lands. + suspendFor(10 * CONST.MILLISECONDS_PER_SECOND); + expect(screen.getByText('00:16')).toBeOnTheScreen(); + }); + + it('finishes once the window has elapsed even if the callbacks never arrived', () => { + const onCountdownFinish = jest.fn(); + renderCountdown(onCountdownFinish); + + suspendFor(CONST.REQUEST_CODE_DELAY * CONST.MILLISECONDS_PER_SECOND); + expect(onCountdownFinish).toHaveBeenCalled(); + }); + + it('keeps measuring from a persisted requestedAt when one is passed', () => { + renderCountdown(jest.fn(), BASE_TIME - 8 * CONST.MILLISECONDS_PER_SECOND); + expect(screen.getByText('00:22')).toBeOnTheScreen(); + + suspendFor(10 * CONST.MILLISECONDS_PER_SECOND); + expect(screen.getByText('00:11')).toBeOnTheScreen(); + }); + + it('measures from the resend time after the countdown is reset', () => { + const ref = renderCountdown(); + + tickSeconds(5); + act(() => ref.current?.resetCountdown()); + expect(screen.getByText('00:30')).toBeOnTheScreen(); + + suspendFor(10 * CONST.MILLISECONDS_PER_SECOND); + expect(screen.getByText('00:19')).toBeOnTheScreen(); + }); +}); diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index a81708607145..13fd36c45313 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -728,6 +728,11 @@ describe('DateUtils', () => { it('should clamp to 0 once the window has elapsed', () => { expect(DateUtils.getRemainingSecondsInWindow(Date.now() - 31 * 1000, windowMs)).toBe(0); }); + + // A backward clock correction leaves the request timestamp in the future. + it('should clamp to the full window when the timestamp is in the future', () => { + expect(DateUtils.getRemainingSecondsInWindow(Date.now() + 10 * 1000, windowMs)).toBe(30); + }); }); describe('getTimeOfDayGreetingKey', () => {