diff --git a/packages/react-native-fantom/runner/getFantomTestConfig.js b/packages/react-native-fantom/runner/getFantomTestConfig.js index 3351ea9838d..995671b45e6 100644 --- a/packages/react-native-fantom/runner/getFantomTestConfig.js +++ b/packages/react-native-fantom/runner/getFantomTestConfig.js @@ -46,6 +46,8 @@ const DEFAULT_MODE: FantomTestConfigMode = const FANTOM_FLAG_FORMAT = /^(\w+):(\w+)$/; +const FANTOM_BENCHMARK_SUITE_RE = /\nbenchmark(\s*)\.suite\(/g; + /** * Extracts the Fantom configuration from the test file, specified as part of * the docblock comment. E.g.: @@ -69,7 +71,9 @@ const FANTOM_FLAG_FORMAT = /^(\w+):(\w+)$/; export default function getFantomTestConfig( testPath: string, ): FantomTestConfig { - const docblock = extract(fs.readFileSync(testPath, 'utf8')); + const testContents = fs.readFileSync(testPath, 'utf8'); + + const docblock = extract(testContents); const pragmas = parse(docblock) as DocblockPragmas; const config: FantomTestConfig = { @@ -102,6 +106,10 @@ export default function getFantomTestConfig( default: throw new Error(`Invalid Fantom mode: ${mode}`); } + } else { + if (FANTOM_BENCHMARK_SUITE_RE.test(testContents)) { + config.mode = FantomTestConfigMode.Optimized; + } } const maybeRawFlagConfig = pragmas.fantom_flags; diff --git a/packages/react-native-fantom/src/Benchmark.js b/packages/react-native-fantom/src/Benchmark.js new file mode 100644 index 00000000000..a54f4edc440 --- /dev/null +++ b/packages/react-native-fantom/src/Benchmark.js @@ -0,0 +1,99 @@ +/** + * 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 nullthrows from 'nullthrows'; +import NativeCPUTime from 'react-native/src/private/specs/modules/NativeCPUTime'; +import { + Bench, + type BenchOptions, + type FnOptions, + type TaskResult, +} from 'tinybench'; + +type SuiteOptions = $ReadOnly<{ + ...Pick< + BenchOptions, + 'iterations' | 'time' | 'warmup' | 'warmupIterations' | 'warmupTime', + >, + disableOptimizedBuildCheck?: boolean, +}>; + +type SuiteResults = Array<$ReadOnly>; + +interface SuiteAPI { + add(name: string, fn: () => void, options?: FnOptions): SuiteAPI; + verify(fn: (results: SuiteResults) => void): SuiteAPI; +} + +export function suite( + suiteName: string, + suiteOptions?: ?SuiteOptions, +): SuiteAPI { + const {disableOptimizedBuildCheck, ...benchOptions} = suiteOptions ?? {}; + + const bench = new Bench({ + ...benchOptions, + name: suiteName, + throws: true, + now: () => NativeCPUTime.getCPUTimeNanos() / 1000000, + }); + + const verifyFns = []; + + global.it(suiteName, () => { + if (bench.tasks.length === 0) { + throw new Error('No benchmark tests defined'); + } + + bench.runSync(); + + printBenchmarkResults(bench); + + for (const verify of verifyFns) { + verify(bench.results); + } + + if (!NativeCPUTime.hasAccurateCPUTimeNanosForBenchmarks()) { + throw new Error( + '`NativeCPUTime` module does not provide accurate CPU time information in this environment. Please run the benchmarks in an environment where it does.', + ); + } + + if (__DEV__ && disableOptimizedBuildCheck !== true) { + throw new Error('Benchmarks should not be run in development mode'); + } + }); + + const suiteAPI = { + add(name: string, fn: () => void, options?: FnOptions): SuiteAPI { + bench.add(name, fn, options); + return suiteAPI; + }, + verify(fn: (results: SuiteResults) => void): SuiteAPI { + verifyFns.push(fn); + return suiteAPI; + }, + }; + + return suiteAPI; +} + +function printBenchmarkResults(bench: Bench) { + const longestTaskNameLength = bench.tasks.reduce( + (maxLength, task) => Math.max(maxLength, task.name.length), + 0, + ); + const separatorWidth = 121 + longestTaskNameLength - 'Task name'.length; + + console.log('-'.repeat(separatorWidth)); + console.log(bench.name); + console.table(nullthrows(bench.table())); + console.log('-'.repeat(separatorWidth) + '\n'); +} diff --git a/packages/react-native-fantom/src/index.js b/packages/react-native-fantom/src/index.js index cf78e5d9fda..0072ab4d723 100644 --- a/packages/react-native-fantom/src/index.js +++ b/packages/react-native-fantom/src/index.js @@ -14,6 +14,7 @@ import type { } from './getFantomRenderedOutput'; import type {MixedElement} from 'react'; +import * as Benchmark from './Benchmark'; import getFantomRenderedOutput from './getFantomRenderedOutput'; import ReactFabric from 'react-native/Libraries/Renderer/shims/ReactFabric'; import NativeFantom from 'react-native/src/private/specs/modules/NativeFantom'; @@ -63,7 +64,7 @@ class Root { this.#hasRendered = true; } - ReactFabric.render(element, this.#surfaceId, () => {}, true); + ReactFabric.render(element, this.#surfaceId, null, true); } getMountingLogs(): Array { @@ -137,3 +138,69 @@ export function runWorkLoop(): void { export function createRoot(rootConfig?: RootConfig): Root { return new Root(rootConfig); } + +export const benchmark = Benchmark; + +/** + * Quick and dirty polyfills required by tinybench. + */ + +if (typeof global.Event === 'undefined') { + global.Event = class Event { + constructor() {} + }; +} else { + console.warn( + 'The global Event class is already defined. If this API is already defined by React Native, you might want to remove this logic.', + ); +} + +if (typeof global.EventTarget === 'undefined') { + global.EventTarget = class EventTarget { + listeners: $FlowFixMe; + + constructor() { + this.listeners = {}; + } + + addEventListener(type: string, cb: () => void) { + if (!(type in this.listeners)) { + this.listeners[type] = []; + } + this.listeners[type].push(cb); + } + + removeEventListener(type: string, cb: () => void): void { + if (!(type in this.listeners)) { + return; + } + let handlers = this.listeners[type]; + for (let i in handlers) { + if (cb === handlers[i]) { + handlers.splice(i, 1); + return; + } + } + } + + dispatchEvent(type: string, event: Event) { + if (!(type in this.listeners)) { + return; + } + let handlers = this.listeners[type]; + for (let i in handlers) { + handlers[i].call(this, event); + } + } + + clearEventListeners() { + for (let i in this.listeners) { + delete this.listeners[i]; + } + } + }; +} else { + console.warn( + 'The global Event class is already defined. If this API is already defined by React Native, you might want to remove this logic.', + ); +}