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();