diff --git a/packages/react-native/ReactCommon/react/timing/CMakeLists.txt b/packages/react-native/ReactCommon/react/timing/CMakeLists.txt index c356cb182d14..93f99af2645c 100644 --- a/packages/react-native/ReactCommon/react/timing/CMakeLists.txt +++ b/packages/react-native/ReactCommon/react/timing/CMakeLists.txt @@ -11,5 +11,7 @@ include(${REACT_COMMON_DIR}/cmake-utils/react-native-flags.cmake) add_library(react_timing INTERFACE) target_include_directories(react_timing INTERFACE ${REACT_COMMON_DIR}) +target_link_libraries(react_timing INTERFACE + react_debug) target_compile_reactnative_options(react_timing INTERFACE) target_compile_options(react_timing INTERFACE -Wpedantic) diff --git a/packages/react-native/ReactCommon/react/timing/React-timing.podspec b/packages/react-native/ReactCommon/react/timing/React-timing.podspec index cb781783c819..e3d90ca2ec4b 100644 --- a/packages/react-native/ReactCommon/react/timing/React-timing.podspec +++ b/packages/react-native/ReactCommon/react/timing/React-timing.podspec @@ -41,4 +41,6 @@ Pod::Spec.new do |s| s.module_name = "React_timing" s.header_mappings_dir = "./" end + + add_dependency(s, "React-debug") end diff --git a/packages/react-native/ReactCommon/react/timing/primitives.h b/packages/react-native/ReactCommon/react/timing/primitives.h index 0bb3311dea54..d3e3097674b6 100644 --- a/packages/react-native/ReactCommon/react/timing/primitives.h +++ b/packages/react-native/ReactCommon/react/timing/primitives.h @@ -7,7 +7,9 @@ #pragma once +#include #include +#include namespace facebook::react { @@ -193,11 +195,10 @@ class HighResTimeStamp { const HighResDuration& rhs); public: - HighResTimeStamp() noexcept - : chronoTimePoint_(std::chrono::steady_clock::now()) {} + HighResTimeStamp() noexcept : chronoTimePoint_(chronoNow()) {} static HighResTimeStamp now() noexcept { - return HighResTimeStamp(std::chrono::steady_clock::now()); + return HighResTimeStamp(chronoNow()); } static constexpr HighResTimeStamp min() noexcept { @@ -228,6 +229,14 @@ class HighResTimeStamp { return HighResTimeStamp(chronoTimePoint); } +#ifdef REACT_NATIVE_DEBUG + static void setTimeStampProviderForTesting( + std::function&& + timeStampProvider) { + getTimeStampProvider() = std::move(timeStampProvider); + } +#endif + // This method is provided for convenience, if you need to convert // HighResTimeStamp to some common epoch with time stamps from other sources. constexpr std::chrono::steady_clock::time_point toChronoSteadyClockTimePoint() @@ -275,6 +284,25 @@ class HighResTimeStamp { : chronoTimePoint_(chronoTimePoint) {} std::chrono::steady_clock::time_point chronoTimePoint_; + +#ifdef REACT_NATIVE_DEBUG + static std::function& + getTimeStampProvider() { + static std::function + timeStampProvider = nullptr; + return timeStampProvider; + } + + static std::chrono::steady_clock::time_point chronoNow() { + auto& timeStampProvider = getTimeStampProvider(); + return timeStampProvider != nullptr ? timeStampProvider() + : std::chrono::steady_clock::now(); + } +#else + inline static std::chrono::steady_clock::time_point chronoNow() { + return std::chrono::steady_clock::now(); + } +#endif }; inline constexpr HighResDuration operator-( 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 e9698d723dc8..859d5dda46d3 100644 --- a/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js +++ b/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js @@ -116,6 +116,7 @@ interface Spec extends TurboModule { shadowNode: mixed /* ShadowNode */, ): () => ?number; saveJSMemoryHeapSnapshot: (filePath: string) => void; + forceHighResTimeStamp: (timeStamp: ?number) => void; } export default TurboModuleRegistry.getEnforcing( diff --git a/private/react-native-fantom/src/HighResTimeStampMock.js b/private/react-native-fantom/src/HighResTimeStampMock.js new file mode 100644 index 000000000000..15943b6cb979 --- /dev/null +++ b/private/react-native-fantom/src/HighResTimeStampMock.js @@ -0,0 +1,89 @@ +/** + * 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 NativeFantom from 'react-native/src/private/testing/fantom/specs/NativeFantom'; + +/** + * Represents a mocked clock for `HighResTimeStamp` values. + */ +export interface HighResTimeStampMock { + setTime(now: number): void; + advanceTimeBy(deltaMs: number): void; + uninstall(): void; +} + +let activeMock: ?HighResTimeStampMock; + +/** + * Installs a mock clock for `HighResTimeStamp` values and returns an object + * that allows controlling the returned values. + * + * @example + * ``` + * let mockClock; + * + * afterEach(() => { + * mockClock.uninstall(); + * mockClock = null; + * }); + * + * it('should do something when time passes', () => { + * mockClock = Fantom.installHighResTimeStampMock(); + * mockClock.setTime(10); + * + * doSomething(); + * + * mockClock.advanceTimeBy(100); + * + * doSomethingElse(); + * + * expect(someSideEffectProduced).toBe(true); + * }); + * ``` + */ +export function installHighResTimeStampMock(): HighResTimeStampMock { + if (activeMock != null) { + throw new Error( + 'Cannot install HighResTimeStamp mock because there is another mock installed already. Reuse the same mock or uninstall the previous one first.', + ); + } + + let mockedTimeStamp = 0; + + const mock: HighResTimeStampMock = { + setTime: now => { + if (now < mockedTimeStamp) { + throw new Error('The mocked time cannot be decreased'); + } + mockedTimeStamp = now; + NativeFantom.forceHighResTimeStamp(mockedTimeStamp); + }, + advanceTimeBy: delta => { + if (delta < 0) { + throw new Error('The mocked time cannot be decreased'); + } + mockedTimeStamp += delta; + mock.setTime(mockedTimeStamp); + }, + uninstall: () => { + if (activeMock === mock) { + NativeFantom.forceHighResTimeStamp(undefined); + activeMock = null; + } + }, + }; + + // Set default value + mock.setTime(mockedTimeStamp); + + activeMock = mock; + + return mock; +} diff --git a/private/react-native-fantom/src/__tests__/FantomHighResTimeStampMock-itest.js b/private/react-native-fantom/src/__tests__/FantomHighResTimeStampMock-itest.js new file mode 100644 index 000000000000..643a0331c14b --- /dev/null +++ b/private/react-native-fantom/src/__tests__/FantomHighResTimeStampMock-itest.js @@ -0,0 +1,137 @@ +/** + * 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. + * + * @fantom_mode dev + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HighResTimeStampMock} from '../HighResTimeStampMock'; + +import * as Fantom from '@react-native/fantom'; + +const pendingMocks: Set = new Set(); + +function installHighResTimeStampMock(): HighResTimeStampMock { + const mock = Fantom.installHighResTimeStampMock(); + pendingMocks.add(mock); + return mock; +} + +describe('Fantom HighResTimeStamp mocks', () => { + afterEach(() => { + for (const mock of pendingMocks) { + mock.uninstall(); + } + pendingMocks.clear(); + }); + + it('sets a default value', () => { + expect(performance.now()).toBeGreaterThan(0); + + installHighResTimeStampMock(); + + expect(performance.now()).toBe(0); + }); + + it('sets custom values', () => { + const mock = installHighResTimeStampMock(); + + expect(performance.now()).toBe(0); + + mock.setTime(50); + + expect(performance.now()).toBe(50); + + mock.setTime(70); + + expect(performance.now()).toBe(70); + + mock.advanceTimeBy(5); + + expect(performance.now()).toBe(75); + + mock.setTime(90); + + expect(performance.now()).toBe(90); + }); + + it('throws an error when trying to set a time in the past', () => { + const mock = installHighResTimeStampMock(); + + expect(performance.now()).toBe(0); + + mock.setTime(50); + + expect(performance.now()).toBe(50); + + expect(() => { + mock.setTime(40); + }).toThrow('The mocked time cannot be decreased'); + + expect(performance.now()).toBe(50); + + expect(() => { + mock.advanceTimeBy(-1); + }).toThrow('The mocked time cannot be decreased'); + + expect(performance.now()).toBe(50); + }); + + it('allows uninstalling', () => { + expect(performance.now()).toBeGreaterThan(0); + + const mock = installHighResTimeStampMock(); + + expect(performance.now()).toBe(0); + + mock.uninstall(); + + expect(performance.now()).toBeGreaterThan(0); + }); + + it('does nothing when uninstalling multiple times', () => { + expect(performance.now()).toBeGreaterThan(0); + + const mock = installHighResTimeStampMock(); + + expect(performance.now()).toBe(0); + + mock.uninstall(); + mock.uninstall(); + mock.uninstall(); + + expect(performance.now()).toBeGreaterThan(0); + }); + + it('throws an error when installing multiple mocks at the same time', () => { + installHighResTimeStampMock(); + expect(() => installHighResTimeStampMock()).toThrow( + 'Cannot install HighResTimeStamp mock because there is another mock installed already. Reuse the same mock or uninstall the previous one first.', + ); + }); + + it('does not uninstall other mocks', () => { + const initialMock = installHighResTimeStampMock(); + + expect(performance.now()).toBe(0); + + initialMock.uninstall(); + + expect(performance.now()).toBeGreaterThan(0); + + installHighResTimeStampMock(); + + expect(performance.now()).toBe(0); + + initialMock.uninstall(); + + // Has no effect on the current mock + expect(performance.now()).toBe(0); + }); +}); diff --git a/private/react-native-fantom/src/__tests__/FantomHighResTimeStampMockOpt-itest.js b/private/react-native-fantom/src/__tests__/FantomHighResTimeStampMockOpt-itest.js new file mode 100644 index 000000000000..ec39a2fec8af --- /dev/null +++ b/private/react-native-fantom/src/__tests__/FantomHighResTimeStampMockOpt-itest.js @@ -0,0 +1,22 @@ +/** + * 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. + * + * @fantom_mode opt + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import * as Fantom from '@react-native/fantom'; + +describe('Fantom HighResTimeStamp mocks (optimized builds)', () => { + it('does not allow mocking the time in optimized builds', () => { + expect(() => Fantom.installHighResTimeStampMock()).toThrow( + 'Mocking timers is not supported in optimized builds', + ); + }); +}); diff --git a/private/react-native-fantom/src/index.js b/private/react-native-fantom/src/index.js index 1be3fd4af5b3..fcfeec2ea22a 100644 --- a/private/react-native-fantom/src/index.js +++ b/private/react-native-fantom/src/index.js @@ -682,6 +682,8 @@ export function saveJSMemoryHeapSnapshot(filePath: string): void { NativeFantom.saveJSMemoryHeapSnapshot(filePath); } +export * from './HighResTimeStampMock'; + function runLogBoxCheck() { if (isLogBoxCheckEnabled && LogBox.isInstalled()) { const message = diff --git a/private/react-native-fantom/tester/src/NativeFantom.cpp b/private/react-native-fantom/tester/src/NativeFantom.cpp index 0223f5b9063b..a7fae8660291 100644 --- a/private/react-native-fantom/tester/src/NativeFantom.cpp +++ b/private/react-native-fantom/tester/src/NativeFantom.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -247,4 +248,28 @@ void NativeFantom::saveJSMemoryHeapSnapshot( runtime.instrumentation().createSnapshotToFile(filePath); } +#ifdef REACT_NATIVE_DEBUG + +void NativeFantom::forceHighResTimeStamp( + jsi::Runtime& /*runtime*/, + std::optional now) { + if (now) { + HighResTimeStamp::setTimeStampProviderForTesting( + [now] { return now->toChronoSteadyClockTimePoint(); }); + } else { + HighResTimeStamp::setTimeStampProviderForTesting(nullptr); + } +} + +#else + +void NativeFantom::forceHighResTimeStamp( + jsi::Runtime& runtime, + std::optional /*now*/) { + throw jsi::JSError( + runtime, "Mocking timers is not supported in optimized builds"); +} + +#endif + } // namespace facebook::react diff --git a/private/react-native-fantom/tester/src/NativeFantom.h b/private/react-native-fantom/tester/src/NativeFantom.h index 923ad92037b8..67eca0d15d4a 100644 --- a/private/react-native-fantom/tester/src/NativeFantom.h +++ b/private/react-native-fantom/tester/src/NativeFantom.h @@ -146,6 +146,10 @@ class NativeFantom : public NativeFantomCxxSpec { jsi::Runtime& runtime, const std::string& filePath); + void forceHighResTimeStamp( + jsi::Runtime& runtime, + std::optional now); + private: TesterAppDelegate& appDelegate_; SurfaceId nextSurfaceId_ = 1;