Skip to content
Closed
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
2 changes: 2 additions & 0 deletions packages/react-native/ReactCommon/react/timing/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
34 changes: 31 additions & 3 deletions packages/react-native/ReactCommon/react/timing/primitives.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

#pragma once

#include <react/debug/flags.h>
#include <chrono>
#include <functional>

namespace facebook::react {

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -228,6 +229,14 @@ class HighResTimeStamp {
return HighResTimeStamp(chronoTimePoint);
}

#ifdef REACT_NATIVE_DEBUG
static void setTimeStampProviderForTesting(
std::function<std::chrono::steady_clock::time_point()>&&
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()
Expand Down Expand Up @@ -275,6 +284,25 @@ class HighResTimeStamp {
: chronoTimePoint_(chronoTimePoint) {}

std::chrono::steady_clock::time_point chronoTimePoint_;

#ifdef REACT_NATIVE_DEBUG
static std::function<std::chrono::steady_clock::time_point()>&
getTimeStampProvider() {
static std::function<std::chrono::steady_clock::time_point()>
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-(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ interface Spec extends TurboModule {
shadowNode: mixed /* ShadowNode */,
): () => ?number;
saveJSMemoryHeapSnapshot: (filePath: string) => void;
forceHighResTimeStamp: (timeStamp: ?number) => void;
}

export default TurboModuleRegistry.getEnforcing<Spec>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,12 @@ import type {
} from 'react-native/src/private/webapis/performance/PerformanceObserver';

import * as Fantom from '@react-native/fantom';
import nullthrows from 'nullthrows';
import setUpPerformanceObserver from 'react-native/src/private/setup/setUpPerformanceObserver';
import {PerformanceLongTaskTiming} from 'react-native/src/private/webapis/performance/LongTasks';
import {PerformanceObserver} from 'react-native/src/private/webapis/performance/PerformanceObserver';

setUpPerformanceObserver();

function sleep(ms: number) {
const end = performance.now() + ms;
while (performance.now() < end) {}
}

function ensurePerformanceLongTaskTiming(
value: mixed,
): PerformanceLongTaskTiming {
Expand All @@ -41,16 +35,12 @@ function ensurePerformanceLongTaskTiming(
}

let observer: ?PerformanceObserver;
let pendingHighResTimeStampMock: ?Fantom.HighResTimeStampMock;

const constants = Fantom.getConstants();
const isGithubCI = constants.isOSS && constants.isRunningFromCI;

// Skip the test on Github CI.
// In that environment, execution speed is unreliable so some of the tasks
// we schedule as part of the test run (even the task to report long tasks) are
// also being reported as long, and we don't have a way to distinguish those
// from the intentionally long tasks we're scheduling for testing.
const describe = isGithubCI ? global.describe.skip : global.describe;
function installHighResTimeStampMock() {
pendingHighResTimeStampMock = Fantom.installHighResTimeStampMock();
return pendingHighResTimeStampMock;
}

describe('LongTasks API', () => {
afterEach(() => {
Expand All @@ -60,54 +50,62 @@ describe('LongTasks API', () => {
observer = null;
});
}

if (pendingHighResTimeStampMock) {
pendingHighResTimeStampMock.uninstall();
pendingHighResTimeStampMock = null;
}
});

it('does NOT report short tasks (under 50ms)', () => {
const callback = jest.fn();

const mockClock = installHighResTimeStampMock();

mockClock.setTime(0);

Fantom.runTask(() => {
observer = new PerformanceObserver(callback);
observer.observe({entryTypes: ['longtask']});
});

const initialCallCount = callback.mock.calls.length;
expect(callback).not.toHaveBeenCalled();

Fantom.runTask(() => {
// Short task.
mockClock.advanceTimeBy(10);
});

expect(callback).toHaveBeenCalledTimes(initialCallCount);
expect(callback).not.toHaveBeenCalled();

Fantom.runTask(() => {
// Slightly longer task, but still not long.
sleep(40);
mockClock.advanceTimeBy(40);
});

expect(callback).toHaveBeenCalledTimes(initialCallCount);
expect(callback).not.toHaveBeenCalled();
});

it('reports long tasks (over 50ms)', () => {
const callback = jest.fn();

const mockClock = installHighResTimeStampMock();

Fantom.runTask(() => {
observer = new PerformanceObserver(callback);
observer.observe({entryTypes: ['longtask']});
});

const initialCallCount = callback.mock.calls.length;
expect(callback).not.toHaveBeenCalled();

const beforeTaskStartTime = performance.now();
let afterTaskStartTime;
mockClock.setTime(10);

Fantom.runTask(() => {
afterTaskStartTime = performance.now();
// Long task.
sleep(51);
mockClock.advanceTimeBy(51);
});

const afterTaskEndTime = performance.now();

expect(callback).toHaveBeenCalledTimes(initialCallCount + 1);
expect(callback).toHaveBeenCalledTimes(1);

const [entries, _observer, options] = callback.mock
.lastCall as $FlowFixMe as [
Expand All @@ -127,66 +125,64 @@ describe('LongTasks API', () => {

expect(entry.name).toBe('self');
expect(entry.entryType).toBe('longtask');
expect(entry.startTime).toBeGreaterThanOrEqual(beforeTaskStartTime);
expect(entry.startTime).toBeLessThanOrEqual(nullthrows(afterTaskStartTime));
expect(entry.duration).toBeGreaterThanOrEqual(51);
expect(entry.duration).toBeLessThanOrEqual(
afterTaskEndTime - beforeTaskStartTime,
);
expect(entry.startTime).toBe(10);
expect(entry.duration).toBe(51);
expect(entry.attribution).toEqual([]);
});

describe('tasks that yield', () => {
it('should NOT be reported if they are longer than 50ms but had yielding opportunities in intervals shorter than 50ms', () => {
const callback = jest.fn();

const mockClock = installHighResTimeStampMock();

Fantom.runTask(() => {
observer = new PerformanceObserver(callback);
observer.observe({entryTypes: ['longtask']});
});

const initialCallCount = callback.mock.calls.length;
expect(callback).not.toHaveBeenCalled();

const shouldYield = global.nativeRuntimeScheduler.unstable_shouldYield;

mockClock.setTime(10);

Fantom.runTask(() => {
sleep(30);
mockClock.advanceTimeBy(30);
shouldYield();
sleep(30);
mockClock.advanceTimeBy(30);
shouldYield();
sleep(30);
mockClock.advanceTimeBy(30);
});

expect(callback).toHaveBeenCalledTimes(initialCallCount);
expect(callback).not.toHaveBeenCalled();
});

it('should be reported if running for longer than 50ms between yielding opportunities', () => {
const callback = jest.fn();

const mockClock = installHighResTimeStampMock();

Fantom.runTask(() => {
observer = new PerformanceObserver(callback);
observer.observe({entryTypes: ['longtask']});
});

const initialCallCount = callback.mock.calls.length;
expect(callback).not.toHaveBeenCalled();

const shouldYield = global.nativeRuntimeScheduler.unstable_shouldYield;

const beforeTaskStartTime = performance.now();
let afterTaskStartTime;
mockClock.setTime(10);

Fantom.runTask(() => {
afterTaskStartTime = performance.now();
sleep(40);
mockClock.advanceTimeBy(40);
shouldYield();
sleep(51); // long interval without yielding
mockClock.advanceTimeBy(51); // long interval without yielding
shouldYield();
sleep(40);
mockClock.advanceTimeBy(40);
});

const afterTaskEndTime = performance.now();

expect(callback).toHaveBeenCalledTimes(initialCallCount + 1);
expect(callback).toHaveBeenCalledTimes(1);

const entries = callback.mock.lastCall[0] as PerformanceObserverEntryList;
const allEntries = entries.getEntries();
Expand All @@ -195,14 +191,8 @@ describe('LongTasks API', () => {
const entry = ensurePerformanceLongTaskTiming(allEntries[0]);
expect(entry.name).toBe('self');
expect(entry.entryType).toBe('longtask');
expect(entry.startTime).toBeGreaterThanOrEqual(beforeTaskStartTime);
expect(entry.startTime).toBeLessThanOrEqual(
nullthrows(afterTaskStartTime),
);
expect(entry.duration).toBeGreaterThanOrEqual(131); // just the sum of the sleep times in the task
expect(entry.duration).toBeLessThanOrEqual(
afterTaskEndTime - beforeTaskStartTime,
);
expect(entry.startTime).toBe(10);
expect(entry.duration).toBe(131);
expect(entry.attribution).toEqual([]);
});
});
Expand Down
89 changes: 89 additions & 0 deletions private/react-native-fantom/src/HighResTimeStampMock.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading