From b0a3c1f40d5274772789bc7c3af03f0a9f07ebf3 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Fri, 9 Oct 2026 04:39:44 -0700 Subject: [PATCH] Run the work loop between timers in the timer mock Summary: `advanceTimersByTime` fired every timer due in the window first and ran their callbacks afterwards, in one work loop at the end. That is not what an idle event loop does over that much time: per the [HTML timer initialization steps](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timer-initialisation-steps), each timer's task runs when it becomes due, a repeating timer is re-armed only after its callback has run, and timers scheduled by a callback can come due before later ones. Tests could not observe a callback running at its due time, and a timer implementation that re-arms after the callback (as the spec says) could not be tested at all, because its next fire would only be scheduled after the whole advance. Here, the mock fires one timer at a time and runs the work loop after each one, for both `advanceTimersByTime` and `runAllTimers`. `NativeFantom.advanceTimers` / `runAllTimers` become `advanceTimersToNextDue` (fires the earliest timer due within the window and returns the time left) and `runNextTimer`; the loop and its 100,000-fire safety bound move to `TimerMock.js`. Changelog: [Internal] Differential Revision: D124119880 --- .../testing/fantom/specs/NativeFantom.js | 4 +- private/react-native-fantom/src/TimerMock.js | 31 ++++-- .../src/__tests__/FantomTimerMock-itest.js | 33 ++++++ .../tester/src/FantomTimerRegistry.cpp | 103 +++++++----------- .../tester/src/FantomTimerRegistry.h | 25 +++-- .../tester/src/NativeFantom.cpp | 10 +- .../tester/src/NativeFantom.h | 4 +- .../tester/src/TesterAppDelegate.cpp | 14 +-- .../tester/src/TesterAppDelegate.h | 4 +- 9 files changed, 134 insertions(+), 94 deletions(-) diff --git a/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js b/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js index d5b487f6cb67..75526c3dcf17 100644 --- a/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js +++ b/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js @@ -125,8 +125,8 @@ interface Spec extends TurboModule { saveJSMemoryHeapSnapshot: (filePath: string) => void; forceHighResTimeStamp: (timeStamp: ?number) => void; setTimerMockEnabled: (enabled: boolean) => void; - advanceTimers: (deltaMs: number) => void; - runAllTimers: () => void; + advanceTimersToNextDue: (deltaMs: number) => number; + runNextTimer: () => boolean; getPendingTimerCount: () => number; startJSSamplingProfiler: () => void; stopJSSamplingProfilerAndSaveToFile: (filePath: string) => void; diff --git a/private/react-native-fantom/src/TimerMock.js b/private/react-native-fantom/src/TimerMock.js index 6c4ddb1a8121..1879c7de9f6f 100644 --- a/private/react-native-fantom/src/TimerMock.js +++ b/private/react-native-fantom/src/TimerMock.js @@ -16,10 +16,11 @@ import {runWorkLoop} from './index'; */ export interface TimerMock { // Advances the virtual clock by `deltaMs`, firing every timer that becomes - // due (in order), then runs the work loop so the callbacks execute. + // due in order and running the work loop after each one, so each callback + // runs at its due time and timers it schedules can fire in the same advance. advanceTimersByTime(deltaMs: number): void; - // Fires all pending timers (bounded to avoid infinite loops), then runs the - // work loop so the callbacks execute. + // Fires all pending timers in order, running the work loop after each one + // (bounded to avoid infinite loops). runAllTimers(): void; // Returns the number of currently pending (scheduled but not yet fired) // timers. @@ -29,6 +30,10 @@ export interface TimerMock { let activeMock: ?TimerMock; +// Safety bound to avoid spinning forever on self-rescheduling or zero-interval +// recurring timers (mirrors jest fake-timer safeguards). +const MAX_TIMER_FIRES = 100000; + /** * Installs a deterministic timer mock. While installed, `setTimeout` and * `setInterval` callbacks do not fire on their own; they only fire when the @@ -68,12 +73,24 @@ export function installTimerMock(): TimerMock { const mock: TimerMock = { advanceTimersByTime: deltaMs => { - NativeFantom.advanceTimers(deltaMs); - runWorkLoop(); + let remainingMs = deltaMs; + for ( + let fires = 0; + fires < MAX_TIMER_FIRES && remainingMs >= 0; + fires++ + ) { + remainingMs = NativeFantom.advanceTimersToNextDue(remainingMs); + runWorkLoop(); + } }, runAllTimers: () => { - NativeFantom.runAllTimers(); - runWorkLoop(); + for (let fires = 0; fires < MAX_TIMER_FIRES; fires++) { + const fired = NativeFantom.runNextTimer(); + runWorkLoop(); + if (!fired) { + break; + } + } }, getPendingTimerCount: () => NativeFantom.getPendingTimerCount(), uninstall: () => { diff --git a/private/react-native-fantom/src/__tests__/FantomTimerMock-itest.js b/private/react-native-fantom/src/__tests__/FantomTimerMock-itest.js index 5cc85c530247..1668e78989c6 100644 --- a/private/react-native-fantom/src/__tests__/FantomTimerMock-itest.js +++ b/private/react-native-fantom/src/__tests__/FantomTimerMock-itest.js @@ -65,6 +65,39 @@ describe('Fantom timer mocks', () => { expect(timers.getPendingTimerCount()).toBe(0); }); + it('runs each callback at its due time when advancing the clock', () => { + const timers = installTimerMock(); + const calls: Array = []; + + setTimeout(() => { + calls.push('A at 100'); + setTimeout(() => calls.push('B at 150'), 50); + }, 100); + setTimeout(() => calls.push('C at 200'), 200); + + timers.advanceTimersByTime(200); + + expect(calls).toEqual(['A at 100', 'B at 150', 'C at 200']); + }); + + it('runs a recurring timer once per interval when advancing the clock', () => { + const timers = installTimerMock(); + const calls: Array = []; + let count = 0; + + const id = setInterval(() => { + count++; + calls.push(count); + // A timer scheduled from the callback runs before the next interval. + setTimeout(() => calls.push(-count), 0); + }, 100); + + timers.advanceTimersByTime(350); + clearInterval(id); + + expect(calls).toEqual([1, -1, 2, -2, 3, -3]); + }); + it('fires all pending timers with runAllTimers regardless of delay', () => { const timers = installTimerMock(); const first = jest.fn(); diff --git a/private/react-native-fantom/tester/src/FantomTimerRegistry.cpp b/private/react-native-fantom/tester/src/FantomTimerRegistry.cpp index 92f8168f4c6d..c5fb23131f4b 100644 --- a/private/react-native-fantom/tester/src/FantomTimerRegistry.cpp +++ b/private/react-native-fantom/tester/src/FantomTimerRegistry.cpp @@ -13,12 +13,6 @@ namespace facebook::react { -namespace { -// Safety bound to avoid spinning forever on self-rescheduling or zero-interval -// recurring timers (mirrors jest fake-timer safeguards). -constexpr uint32_t kMaxTimerFires = 100000; -} // namespace - void FantomTimerRegistry::createTimer(uint32_t timerId, double delayMs) { scheduleTimer(timerId, delayMs, /* isRecurring */ false); } @@ -82,74 +76,59 @@ void FantomTimerRegistry::fireTimer(uint32_t timerId) { } } -void FantomTimerRegistry::advanceTimersByTime(double deltaMs) { - double targetMs = nowMs_ + std::max(deltaMs, 0.0); - - for (uint32_t fires = 0; fires < kMaxTimerFires; ++fires) { - // Find the earliest timer due at or before the target time, breaking ties - // by timer id (insertion order / FIFO per the timer index). - std::optional nextId; - double nextDueMs = 0.0; - for (const auto& [id, timer] : timers_) { - if (timer.dueTimeMs <= targetMs) { - if (!nextId.has_value() || timer.dueTimeMs < nextDueMs || - (timer.dueTimeMs == nextDueMs && id < *nextId)) { - nextId = id; - nextDueMs = timer.dueTimeMs; - } - } - } - - if (!nextId.has_value()) { - break; +std::optional FantomTimerRegistry::findNextTimer( + double maxDueTimeMs) const { + // The earliest timer due at or before `maxDueTimeMs`, breaking ties by timer + // id (insertion order / FIFO per the timer index). + std::optional nextId; + double nextDueMs = 0.0; + for (const auto& [id, timer] : timers_) { + if (timer.dueTimeMs <= maxDueTimeMs && + (!nextId.has_value() || timer.dueTimeMs < nextDueMs || + (timer.dueTimeMs == nextDueMs && id < *nextId))) { + nextId = id; + nextDueMs = timer.dueTimeMs; } + } + return nextId; +} - auto it = timers_.find(*nextId); - Timer timer = it->second; +void FantomTimerRegistry::fireAndAdvanceTo(uint32_t timerId) { + auto it = timers_.find(timerId); + Timer timer = it->second; - // Advance virtual time to this timer's due time before firing. - nowMs_ = timer.dueTimeMs; + nowMs_ = timer.dueTimeMs; - if (timer.isRecurring) { - it->second.dueTimeMs = timer.dueTimeMs + timer.intervalMs; - } else { - timers_.erase(it); - } - - fireTimer(timer.timerId); + if (timer.isRecurring) { + it->second.dueTimeMs = timer.dueTimeMs + timer.intervalMs; + } else { + timers_.erase(it); } - nowMs_ = targetMs; + fireTimer(timer.timerId); } -void FantomTimerRegistry::runAllTimers() { - for (uint32_t fires = 0; fires < kMaxTimerFires && !timers_.empty(); - ++fires) { - // Fire the earliest pending timer (by due time, then id). - uint32_t nextId = 0; - double nextDueMs = 0.0; - bool found = false; - for (const auto& [id, timer] : timers_) { - if (!found || timer.dueTimeMs < nextDueMs || - (timer.dueTimeMs == nextDueMs && id < nextId)) { - nextId = id; - nextDueMs = timer.dueTimeMs; - found = true; - } - } +double FantomTimerRegistry::advanceTimersToNextDue(double deltaMs) { + double targetMs = nowMs_ + std::max(deltaMs, 0.0); - auto it = timers_.find(nextId); - Timer timer = it->second; - nowMs_ = timer.dueTimeMs; + auto nextId = findNextTimer(targetMs); + if (!nextId.has_value()) { + nowMs_ = targetMs; + return -1; + } - if (timer.isRecurring) { - it->second.dueTimeMs = timer.dueTimeMs + timer.intervalMs; - } else { - timers_.erase(it); - } + fireAndAdvanceTo(*nextId); + return targetMs - nowMs_; +} - fireTimer(timer.timerId); +bool FantomTimerRegistry::runNextTimer() { + auto nextId = findNextTimer(std::numeric_limits::infinity()); + if (!nextId.has_value()) { + return false; } + + fireAndAdvanceTo(*nextId); + return true; } } // namespace facebook::react diff --git a/private/react-native-fantom/tester/src/FantomTimerRegistry.h b/private/react-native-fantom/tester/src/FantomTimerRegistry.h index 849708c98ee9..ca86907a6773 100644 --- a/private/react-native-fantom/tester/src/FantomTimerRegistry.h +++ b/private/react-native-fantom/tester/src/FantomTimerRegistry.h @@ -8,8 +8,11 @@ #pragma once #include + #include +#include #include +#include #include namespace facebook::react { @@ -33,7 +36,7 @@ namespace facebook::react { * * Firing a timer calls `TimerManager::callTimer`, which enqueues the JS * callback on the RuntimeScheduler; the caller is expected to run the work loop - * afterwards to execute the callbacks. + * after each fired timer to execute its callback. * * All methods are expected to be called on the JS thread (Fantom is * single-threaded), so no locking is required. @@ -58,13 +61,19 @@ class FantomTimerRegistry : public PlatformTimerRegistry { void setMockEnabled(bool enabled); bool isMockEnabled() const noexcept; - // Advances the virtual clock by `deltaMs`, firing every timer that becomes - // due (in due-time order), re-arming recurring timers along the way. - void advanceTimersByTime(double deltaMs); + // Advances the virtual clock to the earliest timer due within `deltaMs` and + // fires it, re-arming it if it is recurring. Returns the part of `deltaMs` + // left after that timer's due time, or -1 if no timer was due, in which case + // the clock has advanced by the whole `deltaMs`. + // + // It fires one timer per call so that the caller can run the work loop in + // between: a timer's callback then runs at its due time, before any later + // timer fires, and timers it schedules can fire within the same advance. + double advanceTimersToNextDue(double deltaMs); - // Fires all currently pending timers until none remain (bounded to avoid - // infinite loops with self-rescheduling/recurring timers). - void runAllTimers(); + // Fires the earliest pending timer regardless of its due time, advancing the + // virtual clock to it. Returns false if no timer was pending. + bool runNextTimer(); uint32_t getPendingTimerCount() const noexcept; @@ -77,6 +86,8 @@ class FantomTimerRegistry : public PlatformTimerRegistry { }; void scheduleTimer(uint32_t timerId, double delayMs, bool isRecurring); + std::optional findNextTimer(double maxDueTimeMs) const; + void fireAndAdvanceTo(uint32_t timerId); void fireTimer(uint32_t timerId); std::weak_ptr timerManager_; diff --git a/private/react-native-fantom/tester/src/NativeFantom.cpp b/private/react-native-fantom/tester/src/NativeFantom.cpp index 7a1766c60fd5..0d5574632f8d 100644 --- a/private/react-native-fantom/tester/src/NativeFantom.cpp +++ b/private/react-native-fantom/tester/src/NativeFantom.cpp @@ -67,12 +67,14 @@ void NativeFantom::setTimerMockEnabled( appDelegate_.setTimerMockEnabled(enabled); } -void NativeFantom::advanceTimers(jsi::Runtime& /*runtime*/, double deltaMs) { - appDelegate_.advanceTimers(deltaMs); +double NativeFantom::advanceTimersToNextDue( + jsi::Runtime& /*runtime*/, + double deltaMs) { + return appDelegate_.advanceTimersToNextDue(deltaMs); } -void NativeFantom::runAllTimers(jsi::Runtime& /*runtime*/) { - appDelegate_.runAllTimers(); +bool NativeFantom::runNextTimer(jsi::Runtime& /*runtime*/) { + return appDelegate_.runNextTimer(); } double NativeFantom::getPendingTimerCount(jsi::Runtime& /*runtime*/) { diff --git a/private/react-native-fantom/tester/src/NativeFantom.h b/private/react-native-fantom/tester/src/NativeFantom.h index 2522e7c2bf48..f77574c999e0 100644 --- a/private/react-native-fantom/tester/src/NativeFantom.h +++ b/private/react-native-fantom/tester/src/NativeFantom.h @@ -131,8 +131,8 @@ class NativeFantom : public NativeFantomCxxSpec { void forceHighResTimeStamp(jsi::Runtime &runtime, std::optional now); void setTimerMockEnabled(jsi::Runtime &runtime, bool enabled); - void advanceTimers(jsi::Runtime &runtime, double deltaMs); - void runAllTimers(jsi::Runtime &runtime); + double advanceTimersToNextDue(jsi::Runtime &runtime, double deltaMs); + bool runNextTimer(jsi::Runtime &runtime); double getPendingTimerCount(jsi::Runtime &runtime); void startJSSamplingProfiler(jsi::Runtime &runtime); diff --git a/private/react-native-fantom/tester/src/TesterAppDelegate.cpp b/private/react-native-fantom/tester/src/TesterAppDelegate.cpp index 544deeebf4f7..d6c5c0b18cea 100644 --- a/private/react-native-fantom/tester/src/TesterAppDelegate.cpp +++ b/private/react-native-fantom/tester/src/TesterAppDelegate.cpp @@ -358,16 +358,14 @@ void TesterAppDelegate::setTimerMockEnabled(bool enabled) { } } -void TesterAppDelegate::advanceTimers(double deltaMs) { - if (timerRegistry_ != nullptr) { - timerRegistry_->advanceTimersByTime(deltaMs); - } +double TesterAppDelegate::advanceTimersToNextDue(double deltaMs) { + return timerRegistry_ != nullptr + ? timerRegistry_->advanceTimersToNextDue(deltaMs) + : -1; } -void TesterAppDelegate::runAllTimers() { - if (timerRegistry_ != nullptr) { - timerRegistry_->runAllTimers(); - } +bool TesterAppDelegate::runNextTimer() { + return timerRegistry_ != nullptr && timerRegistry_->runNextTimer(); } uint32_t TesterAppDelegate::getPendingTimerCount() { diff --git a/private/react-native-fantom/tester/src/TesterAppDelegate.h b/private/react-native-fantom/tester/src/TesterAppDelegate.h index 3f5fc28babff..f33605fd2a06 100644 --- a/private/react-native-fantom/tester/src/TesterAppDelegate.h +++ b/private/react-native-fantom/tester/src/TesterAppDelegate.h @@ -77,8 +77,8 @@ class TesterAppDelegate { // Deterministic timer control (driven from JS via NativeFantom). void setTimerMockEnabled(bool enabled); - void advanceTimers(double deltaMs); - void runAllTimers(); + double advanceTimersToNextDue(double deltaMs); + bool runNextTimer(); uint32_t getPendingTimerCount(); void flushMessageQueue();