Skip to content

Commit 6c481be

Browse files
rubennortefacebook-github-bot
authored andcommitted
Add deterministic timer mock to Fantom (react#57274)
Summary: Fantom could not deterministically fire delayed JS timers: the timer registry it used scheduled timers on a real background thread with real wall-clock delays, so `setTimeout(fn, 100)`/`setInterval` callbacks never fired within a synchronous test. This adds a mockable timer registry and a public Fantom API to control it from JS, similar to `installHighResTimeStampMock`. - New `Fantom.installTimerMock()` returns a controller with `advanceTimersByTime(ms)`, `runAllTimers()`, `getPendingTimerCount()`, and `uninstall()` (jest fake-timer style). While installed, `setTimeout`/`setInterval` callbacks only fire when the virtual clock is advanced. - New deterministic `FantomTimerRegistry` (no background thread) keyed off a virtual clock, injected via a new optional `platformTimerRegistryFactory` seam on `ReactInstanceConfig` (the default registry is unchanged for all other consumers). - `PlatformTimerRegistry` gains a virtual `setTimerManager` (default no-op) so the registry can be wired polymorphically. - Control flows from JS through new `NativeFantom` methods, the same way the high-res timestamp mock works. Default (non-mock) behavior is preserved: zero-delay `setTimeout` still fires on the next work loop, and existing tests are unaffected. Changelog: [Internal] Differential Revision: D109017304
1 parent e1b0c77 commit 6c481be

26 files changed

Lines changed: 730 additions & 7 deletions

packages/react-native/ReactCommon/react/runtime/PlatformTimerRegistry.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
#pragma once
99

1010
#include <cstdint>
11+
#include <memory>
1112

1213
namespace facebook::react {
1314

15+
class TimerManager;
16+
1417
/**
1518
* This interface is implemented by each platform.
1619
* Responsibility: Call into some platform API to register/schedule, or delete
@@ -27,6 +30,13 @@ class PlatformTimerRegistry {
2730
virtual ~PlatformTimerRegistry() noexcept = default;
2831

2932
virtual void quit() {}
33+
34+
/**
35+
* Provides the owning TimerManager so the registry can fire due timers via
36+
* TimerManager::callTimer. The default is a no-op for platforms that wire the
37+
* TimerManager through other means.
38+
*/
39+
virtual void setTimerManager(std::weak_ptr<TimerManager> /*timerManager*/) {}
3040
};
3141

3242
using TimerManagerDelegate = PlatformTimerRegistry;

packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/ObjCTimerRegistry.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class ObjCTimerRegistry : public facebook::react::PlatformTimerRegistry {
2323
void createTimer(uint32_t timerID, double delayMS) override;
2424
void deleteTimer(uint32_t timerID) override;
2525
void createRecurringTimer(uint32_t timerID, double delayMS) override;
26-
void setTimerManager(std::weak_ptr<facebook::react::TimerManager> timerManager);
26+
void setTimerManager(std::weak_ptr<facebook::react::TimerManager> timerManager) override;
2727
RCTTiming *_Null_unspecified timing;
2828

2929
private:

packages/react-native/ReactCxxPlatform/react/runtime/PlatformTimerRegistryImpl.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class PlatformTimerRegistryImpl : public PlatformTimerRegistry {
3131

3232
void createRecurringTimer(uint32_t timerID, double delayMs) override;
3333

34-
void setTimerManager(std::weak_ptr<TimerManager> timerManager);
34+
void setTimerManager(std::weak_ptr<TimerManager> timerManager) override;
3535

3636
void quit() override;
3737

packages/react-native/ReactCxxPlatform/react/runtime/ReactHost.cpp

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,12 @@ ReactHost::~ReactHost() noexcept {
114114

115115
void ReactHost::createReactInstance() {
116116
// Set up timers
117-
auto platformTimers = std::make_unique<PlatformTimerRegistryImpl>();
117+
std::unique_ptr<PlatformTimerRegistry> platformTimers;
118+
if (reactInstanceConfig_.platformTimerRegistryFactory) {
119+
platformTimers = reactInstanceConfig_.platformTimerRegistryFactory();
120+
} else {
121+
platformTimers = std::make_unique<PlatformTimerRegistryImpl>();
122+
}
118123
auto* platformTimersPtr = platformTimers.get();
119124
auto timerManager = std::make_shared<TimerManager>(std::move(platformTimers));
120125
platformTimersPtr->setTimerManager(timerManager);

packages/react-native/ReactCxxPlatform/react/runtime/ReactInstanceConfig.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@
88
#pragma once
99

1010
#include <react/debug/flags.h>
11+
#include <functional>
12+
#include <memory>
1113
#include <string>
1214

1315
namespace facebook::react {
1416

17+
class PlatformTimerRegistry;
18+
1519
struct ReactInstanceConfig {
1620
std::string appId;
1721
std::string deviceName;
@@ -24,6 +28,12 @@ struct ReactInstanceConfig {
2428
#endif
2529
std::string devServerHost{"localhost"};
2630
uint32_t devServerPort{8081};
31+
32+
// Optional factory used to create the PlatformTimerRegistry for the instance.
33+
// When unset, a default thread-based PlatformTimerRegistryImpl is used. This
34+
// is a seam for tests (e.g. Fantom) to inject a deterministic, mockable timer
35+
// registry.
36+
std::function<std::unique_ptr<PlatformTimerRegistry>()> platformTimerRegistryFactory{nullptr};
2737
};
2838

2939
} // namespace facebook::react

packages/react-native/src/private/testing/fantom/specs/NativeFantom.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ interface Spec extends TurboModule {
124124
): () => ?number;
125125
saveJSMemoryHeapSnapshot: (filePath: string) => void;
126126
forceHighResTimeStamp: (timeStamp: ?number) => void;
127+
setTimerMockEnabled: (enabled: boolean) => void;
128+
advanceTimers: (deltaMs: number) => void;
129+
runAllTimers: () => void;
130+
getPendingTimerCount: () => number;
127131
startJSSamplingProfiler: () => void;
128132
stopJSSamplingProfilerAndSaveToFile: (filePath: string) => void;
129133
setImageResponse(uri: string, imageResponse: ImageResponse): void;
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import type {TimerMock} from '@react-native/fantom';
12+
13+
import * as Fantom from '@react-native/fantom';
14+
15+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
16+
17+
let timers: TimerMock;
18+
19+
beforeEach(() => {
20+
timers = Fantom.installTimerMock();
21+
});
22+
23+
afterEach(() => {
24+
timers.uninstall();
25+
});
26+
27+
describe('setTimeout', () => {
28+
it('does not fire before the delay elapses', () => {
29+
const callback = jest.fn();
30+
setTimeout(callback, 100);
31+
32+
timers.advanceTimersByTime(99);
33+
34+
expect(callback).toHaveBeenCalledTimes(0);
35+
});
36+
37+
it('fires once after the delay elapses', () => {
38+
const callback = jest.fn();
39+
setTimeout(callback, 100);
40+
41+
timers.advanceTimersByTime(100);
42+
43+
expect(callback).toHaveBeenCalledTimes(1);
44+
});
45+
46+
it('does not fire again after firing once', () => {
47+
const callback = jest.fn();
48+
setTimeout(callback, 100);
49+
50+
timers.advanceTimersByTime(1000);
51+
timers.advanceTimersByTime(1000);
52+
53+
expect(callback).toHaveBeenCalledTimes(1);
54+
});
55+
56+
it('passes additional arguments to the callback', () => {
57+
const callback = jest.fn();
58+
setTimeout(callback, 100, 'a', 'b');
59+
60+
timers.advanceTimersByTime(100);
61+
62+
expect(callback).toHaveBeenCalledWith('a', 'b');
63+
});
64+
65+
it('fires multiple timers in order of their due time', () => {
66+
const calls: Array<string> = [];
67+
setTimeout(() => calls.push('second'), 200);
68+
setTimeout(() => calls.push('first'), 100);
69+
70+
timers.advanceTimersByTime(200);
71+
72+
expect(calls).toEqual(['first', 'second']);
73+
});
74+
75+
it('runs timers scheduled by other timers on the next advance', () => {
76+
const callback = jest.fn();
77+
setTimeout(() => {
78+
setTimeout(callback, 100);
79+
}, 100);
80+
81+
timers.advanceTimersByTime(100);
82+
expect(callback).toHaveBeenCalledTimes(0);
83+
84+
timers.advanceTimersByTime(100);
85+
expect(callback).toHaveBeenCalledTimes(1);
86+
});
87+
});
88+
89+
describe('clearTimeout', () => {
90+
it('prevents a pending timer from firing', () => {
91+
const callback = jest.fn();
92+
const id = setTimeout(callback, 100);
93+
94+
clearTimeout(id);
95+
timers.advanceTimersByTime(1000);
96+
97+
expect(callback).toHaveBeenCalledTimes(0);
98+
});
99+
});
100+
101+
describe('setInterval', () => {
102+
it('fires once per interval', () => {
103+
const callback = jest.fn();
104+
const id = setInterval(callback, 100);
105+
106+
timers.advanceTimersByTime(350);
107+
108+
expect(callback).toHaveBeenCalledTimes(3);
109+
110+
clearInterval(id);
111+
});
112+
113+
it('keeps firing across multiple advances until cleared', () => {
114+
const callback = jest.fn();
115+
const id = setInterval(callback, 100);
116+
117+
timers.advanceTimersByTime(100);
118+
expect(callback).toHaveBeenCalledTimes(1);
119+
120+
timers.advanceTimersByTime(200);
121+
expect(callback).toHaveBeenCalledTimes(3);
122+
123+
clearInterval(id);
124+
});
125+
});
126+
127+
describe('clearInterval', () => {
128+
it('stops a recurring timer from firing again', () => {
129+
const callback = jest.fn();
130+
const id = setInterval(callback, 100);
131+
132+
timers.advanceTimersByTime(100);
133+
expect(callback).toHaveBeenCalledTimes(1);
134+
135+
clearInterval(id);
136+
timers.advanceTimersByTime(1000);
137+
138+
expect(callback).toHaveBeenCalledTimes(1);
139+
});
140+
});

private/react-native-fantom/__docs__/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,32 @@ Fantom.scrollTo(scrollViewElement, {
463463
expect(scrollViewElement.scrollTop).toBe(1);
464464
```
465465

466+
#### How can I test logic that relies on timers (`setTimeout`/`setInterval`)?
467+
468+
Install a deterministic timer mock with `Fantom.installTimerMock()`. While
469+
installed, `setTimeout`/`setInterval` callbacks do not fire on their own; you
470+
advance a virtual clock to fire them, similar to `jest.useFakeTimers()`:
471+
472+
```javascript
473+
const timers = Fantom.installTimerMock();
474+
475+
const callback = jest.fn();
476+
setTimeout(callback, 100);
477+
478+
timers.advanceTimersByTime(50);
479+
expect(callback).toHaveBeenCalledTimes(0);
480+
481+
timers.advanceTimersByTime(50);
482+
expect(callback).toHaveBeenCalledTimes(1);
483+
484+
timers.uninstall();
485+
```
486+
487+
`advanceTimersByTime`/`runAllTimers` run the work loop internally, so callbacks
488+
have executed by the time they return. Use `getPendingTimerCount()` to inspect
489+
how many timers are still scheduled, and `uninstall()` (typically in
490+
`afterEach`) to restore the default behavior.
491+
466492
#### What can be tested with Fantom?
467493

468494
Fantom was designed to make it possible to test integration between React and
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import {runWorkLoop} from './index';
12+
import NativeFantom from 'react-native/src/private/testing/fantom/specs/NativeFantom';
13+
14+
/**
15+
* Controls the deterministic timer mock for `setTimeout`/`setInterval`.
16+
*/
17+
export interface TimerMock {
18+
// Advances the virtual clock by `deltaMs`, firing every timer that becomes
19+
// due (in order), then runs the work loop so the callbacks execute.
20+
advanceTimersByTime(deltaMs: number): void;
21+
// Fires all pending timers (bounded to avoid infinite loops), then runs the
22+
// work loop so the callbacks execute.
23+
runAllTimers(): void;
24+
// Returns the number of currently pending (scheduled but not yet fired)
25+
// timers.
26+
getPendingTimerCount(): number;
27+
uninstall(): void;
28+
}
29+
30+
let activeMock: ?TimerMock;
31+
32+
/**
33+
* Installs a deterministic timer mock. While installed, `setTimeout` and
34+
* `setInterval` callbacks do not fire on their own; they only fire when the
35+
* virtual clock is advanced via `advanceTimersByTime` or drained via
36+
* `runAllTimers`. This is the timer equivalent of `installHighResTimeStampMock`.
37+
*
38+
* @example
39+
* ```
40+
* let timers;
41+
*
42+
* afterEach(() => {
43+
* timers?.uninstall();
44+
* timers = null;
45+
* });
46+
*
47+
* it('fires after the delay elapses', () => {
48+
* timers = Fantom.installTimerMock();
49+
* const callback = jest.fn();
50+
*
51+
* setTimeout(callback, 100);
52+
* timers.advanceTimersByTime(50);
53+
* expect(callback).toHaveBeenCalledTimes(0);
54+
*
55+
* timers.advanceTimersByTime(50);
56+
* expect(callback).toHaveBeenCalledTimes(1);
57+
* });
58+
* ```
59+
*/
60+
export function installTimerMock(): TimerMock {
61+
if (activeMock != null) {
62+
throw new Error(
63+
'Cannot install timer mock because there is another mock installed already. Reuse the same mock or uninstall the previous one first.',
64+
);
65+
}
66+
67+
NativeFantom.setTimerMockEnabled(true);
68+
69+
const mock: TimerMock = {
70+
advanceTimersByTime: deltaMs => {
71+
NativeFantom.advanceTimers(deltaMs);
72+
runWorkLoop();
73+
},
74+
runAllTimers: () => {
75+
NativeFantom.runAllTimers();
76+
runWorkLoop();
77+
},
78+
getPendingTimerCount: () => NativeFantom.getPendingTimerCount(),
79+
uninstall: () => {
80+
if (activeMock === mock) {
81+
NativeFantom.setTimerMockEnabled(false);
82+
activeMock = null;
83+
}
84+
},
85+
};
86+
87+
activeMock = mock;
88+
89+
return mock;
90+
}

0 commit comments

Comments
 (0)