Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
#pragma once

#include <cstdint>
#include <memory>

namespace facebook::react {

class TimerManager;

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

virtual void quit() {}

/**
* Provides the owning TimerManager so the registry can fire due timers via
* TimerManager::callTimer. The default is a no-op for platforms that wire the
* TimerManager through other means.
*/
virtual void setTimerManager(std::weak_ptr<TimerManager> /*timerManager*/) {}
};

using TimerManagerDelegate = PlatformTimerRegistry;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class ObjCTimerRegistry : public facebook::react::PlatformTimerRegistry {
void createTimer(uint32_t timerID, double delayMS) override;
void deleteTimer(uint32_t timerID) override;
void createRecurringTimer(uint32_t timerID, double delayMS) override;
void setTimerManager(std::weak_ptr<facebook::react::TimerManager> timerManager);
void setTimerManager(std::weak_ptr<facebook::react::TimerManager> timerManager) override;
RCTTiming *_Null_unspecified timing;

private:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class PlatformTimerRegistryImpl : public PlatformTimerRegistry {

void createRecurringTimer(uint32_t timerID, double delayMs) override;

void setTimerManager(std::weak_ptr<TimerManager> timerManager);
void setTimerManager(std::weak_ptr<TimerManager> timerManager) override;

void quit() override;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,12 @@ ReactHost::~ReactHost() noexcept {

void ReactHost::createReactInstance() {
// Set up timers
auto platformTimers = std::make_unique<PlatformTimerRegistryImpl>();
std::unique_ptr<PlatformTimerRegistry> platformTimers;
if (reactInstanceConfig_.platformTimerRegistryFactory) {
platformTimers = reactInstanceConfig_.platformTimerRegistryFactory();
} else {
platformTimers = std::make_unique<PlatformTimerRegistryImpl>();
}
auto* platformTimersPtr = platformTimers.get();
auto timerManager = std::make_shared<TimerManager>(std::move(platformTimers));
platformTimersPtr->setTimerManager(timerManager);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@
#pragma once

#include <react/debug/flags.h>
#include <functional>
#include <memory>
#include <string>

namespace facebook::react {

class PlatformTimerRegistry;

struct ReactInstanceConfig {
std::string appId;
std::string deviceName;
Expand All @@ -24,6 +28,12 @@ struct ReactInstanceConfig {
#endif
std::string devServerHost{"localhost"};
uint32_t devServerPort{8081};

// Optional factory used to create the PlatformTimerRegistry for the instance.
// When unset, a default thread-based PlatformTimerRegistryImpl is used. This
// is a seam for tests (e.g. Fantom) to inject a deterministic, mockable timer
// registry.
std::function<std::unique_ptr<PlatformTimerRegistry>()> platformTimerRegistryFactory{nullptr};
};

} // namespace facebook::react
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ interface Spec extends TurboModule {
): () => ?number;
saveJSMemoryHeapSnapshot: (filePath: string) => void;
forceHighResTimeStamp: (timeStamp: ?number) => void;
setTimerMockEnabled: (enabled: boolean) => void;
advanceTimers: (deltaMs: number) => void;
runAllTimers: () => void;
getPendingTimerCount: () => number;
startJSSamplingProfiler: () => void;
stopJSSamplingProfilerAndSaveToFile: (filePath: string) => void;
setImageResponse(uri: string, imageResponse: ImageResponse): void;
Expand Down
140 changes: 140 additions & 0 deletions packages/react-native/src/private/timers/__tests__/Timers-itest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

import type {TimerMock} from '@react-native/fantom';

import * as Fantom from '@react-native/fantom';

import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';

let timers: TimerMock;

beforeEach(() => {
timers = Fantom.installTimerMock();
});

afterEach(() => {
timers.uninstall();
});

describe('setTimeout', () => {
it('does not fire before the delay elapses', () => {
const callback = jest.fn();
setTimeout(callback, 100);

timers.advanceTimersByTime(99);

expect(callback).toHaveBeenCalledTimes(0);
});

it('fires once after the delay elapses', () => {
const callback = jest.fn();
setTimeout(callback, 100);

timers.advanceTimersByTime(100);

expect(callback).toHaveBeenCalledTimes(1);
});

it('does not fire again after firing once', () => {
const callback = jest.fn();
setTimeout(callback, 100);

timers.advanceTimersByTime(1000);
timers.advanceTimersByTime(1000);

expect(callback).toHaveBeenCalledTimes(1);
});

it('passes additional arguments to the callback', () => {
const callback = jest.fn();
setTimeout(callback, 100, 'a', 'b');

timers.advanceTimersByTime(100);

expect(callback).toHaveBeenCalledWith('a', 'b');
});

it('fires multiple timers in order of their due time', () => {
const calls: Array<string> = [];
setTimeout(() => calls.push('second'), 200);
setTimeout(() => calls.push('first'), 100);

timers.advanceTimersByTime(200);

expect(calls).toEqual(['first', 'second']);
});

it('runs timers scheduled by other timers on the next advance', () => {
const callback = jest.fn();
setTimeout(() => {
setTimeout(callback, 100);
}, 100);

timers.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledTimes(0);

timers.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledTimes(1);
});
});

describe('clearTimeout', () => {
it('prevents a pending timer from firing', () => {
const callback = jest.fn();
const id = setTimeout(callback, 100);

clearTimeout(id);
timers.advanceTimersByTime(1000);

expect(callback).toHaveBeenCalledTimes(0);
});
});

describe('setInterval', () => {
it('fires once per interval', () => {
const callback = jest.fn();
const id = setInterval(callback, 100);

timers.advanceTimersByTime(350);

expect(callback).toHaveBeenCalledTimes(3);

clearInterval(id);
});

it('keeps firing across multiple advances until cleared', () => {
const callback = jest.fn();
const id = setInterval(callback, 100);

timers.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledTimes(1);

timers.advanceTimersByTime(200);
expect(callback).toHaveBeenCalledTimes(3);

clearInterval(id);
});
});

describe('clearInterval', () => {
it('stops a recurring timer from firing again', () => {
const callback = jest.fn();
const id = setInterval(callback, 100);

timers.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledTimes(1);

clearInterval(id);
timers.advanceTimersByTime(1000);

expect(callback).toHaveBeenCalledTimes(1);
});
});
26 changes: 26 additions & 0 deletions private/react-native-fantom/__docs__/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,32 @@ Fantom.scrollTo(scrollViewElement, {
expect(scrollViewElement.scrollTop).toBe(1);
```

#### How can I test logic that relies on timers (`setTimeout`/`setInterval`)?

Install a deterministic timer mock with `Fantom.installTimerMock()`. While
installed, `setTimeout`/`setInterval` callbacks do not fire on their own; you
advance a virtual clock to fire them, similar to `jest.useFakeTimers()`:

```javascript
const timers = Fantom.installTimerMock();

const callback = jest.fn();
setTimeout(callback, 100);

timers.advanceTimersByTime(50);
expect(callback).toHaveBeenCalledTimes(0);

timers.advanceTimersByTime(50);
expect(callback).toHaveBeenCalledTimes(1);

timers.uninstall();
```

`advanceTimersByTime`/`runAllTimers` run the work loop internally, so callbacks
have executed by the time they return. Use `getPendingTimerCount()` to inspect
how many timers are still scheduled, and `uninstall()` (typically in
`afterEach`) to restore the default behavior.

#### What can be tested with Fantom?

Fantom was designed to make it possible to test integration between React and
Expand Down
90 changes: 90 additions & 0 deletions private/react-native-fantom/src/TimerMock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

import {runWorkLoop} from './index';
import NativeFantom from 'react-native/src/private/testing/fantom/specs/NativeFantom';

/**
* Controls the deterministic timer mock for `setTimeout`/`setInterval`.
*/
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.
advanceTimersByTime(deltaMs: number): void;
// Fires all pending timers (bounded to avoid infinite loops), then runs the
// work loop so the callbacks execute.
runAllTimers(): void;
// Returns the number of currently pending (scheduled but not yet fired)
// timers.
getPendingTimerCount(): number;
uninstall(): void;
}

let activeMock: ?TimerMock;

/**
* Installs a deterministic timer mock. While installed, `setTimeout` and
* `setInterval` callbacks do not fire on their own; they only fire when the
* virtual clock is advanced via `advanceTimersByTime` or drained via
* `runAllTimers`. This is the timer equivalent of `installHighResTimeStampMock`.
*
* @example
* ```
* let timers;
*
* afterEach(() => {
* timers?.uninstall();
* timers = null;
* });
*
* it('fires after the delay elapses', () => {
* timers = Fantom.installTimerMock();
* const callback = jest.fn();
*
* setTimeout(callback, 100);
* timers.advanceTimersByTime(50);
* expect(callback).toHaveBeenCalledTimes(0);
*
* timers.advanceTimersByTime(50);
* expect(callback).toHaveBeenCalledTimes(1);
* });
* ```
*/
export function installTimerMock(): TimerMock {
if (activeMock != null) {
throw new Error(
'Cannot install timer mock because there is another mock installed already. Reuse the same mock or uninstall the previous one first.',
);
}

NativeFantom.setTimerMockEnabled(true);

const mock: TimerMock = {
advanceTimersByTime: deltaMs => {
NativeFantom.advanceTimers(deltaMs);
runWorkLoop();
},
runAllTimers: () => {
NativeFantom.runAllTimers();
runWorkLoop();
},
getPendingTimerCount: () => NativeFantom.getPendingTimerCount(),
uninstall: () => {
if (activeMock === mock) {
NativeFantom.setTimerMockEnabled(false);
activeMock = null;
}
},
};

activeMock = mock;

return mock;
}
Loading
Loading