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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"test-generated-typescript": "tsc -p packages/react-native/types_generated/tsconfig.test.json",
"test": "jest",
"fantom": "./scripts/fantom.sh",
"fantom-cli": "./scripts/fantom-cli.sh",
"trigger-react-native-release": "node ./scripts/releases-local/trigger-react-native-release.js",
"update-lock": "npx yarn-deduplicate"
},
Expand Down
78 changes: 75 additions & 3 deletions private/react-native-fantom/__docs__/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,72 @@ Similar to Jest, you can also run Fantom in watch mode using `--watch`:
yarn fantom <regexForTestFiles> --watch
```

### Interactive REPL

> [!WARNING]
>
> The REPL is experimental — its behavior and APIs may change.

You can start an interactive REPL that evaluates JavaScript against the same
Hermes runtime and React Native environment that Fantom tests use:

```shell
yarn fantom-cli
```

Each line is evaluated in a persistent session (bindings declared on one line
are available on the next) and goes through Metro, so `import`, JSX and Flow
syntax all work. `React`, `ReactNative` and the `Fantom` API are exposed as
globals, so you can explore them — and render surfaces — without importing
anything. Results are printed with a console-style inspector (similar to Chrome
DevTools / Node.js), and pressing <kbd>Tab</kbd> autocompletes global
identifiers, in-scope bindings and object properties.

```text
👻 Fantom REPL — evaluating against Hermes inside the Fantom runtime.
⚠️ Experimental: behavior and APIs may change.
fantom> const x = 21
fantom> x * 2
42
fantom> ({items: [1, 2, 3], nested: {ok: true}})
{ items: [ 1, 2, 3 ], nested: { ok: true } }
```

Press <kbd>Ctrl</kbd>+<kbd>D</kbd> to exit.

Like Node, you can also evaluate a snippet and exit with `-e`, or run a script
file:

```shell
yarn fantom-cli -e "console.log(Fantom.getHostPlatform())"
yarn fantom-cli path/to/script.js
```

In both cases the value of a trailing expression is not printed (use
`console.log`), and a thrown error exits with a non-zero status code.

#### Timers and asynchronous code in the REPL

The REPL runs in the Fantom environment, which uses a **deterministic virtual
clock** — real wall-clock time never passes on its own. A zero-delay
`setTimeout(fn, 0)` runs right after the current line, but timers with a delay
(and `setInterval`) stay pending until the clock is advanced, so this does
**not** print on its own:

```text
fantom> setTimeout(() => console.log('hi'), 1000)
```

Drive timers by installing the timer mock and advancing the clock, exactly like
in a test (see the timers FAQ below):

```text
fantom> const timers = Fantom.installTimerMock()
fantom> setTimeout(() => console.log('hi'), 1000)
fantom> timers.advanceTimersByTime(1000)
hi
```

### Conventions

- Place test files in `__tests__` directories alongside the code being tested.
Expand Down Expand Up @@ -465,9 +531,15 @@ 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()`:
Fantom runs on a **deterministic virtual clock**: wall-clock time never advances
on its own, and Fantom decides when the event loop and timers run. In the
default environment a zero-delay `setTimeout(fn, 0)` is dispatched on the next
work-loop tick, but timers with a positive delay (and any `setInterval`) stay
pending and never fire unless the virtual clock is advanced.

To advance the clock and run those callbacks, install a deterministic timer mock
with `Fantom.installTimerMock()`. While installed, you advance a virtual clock to
fire them, similar to `jest.useFakeTimers()`:

```javascript
const timers = Fantom.installTimerMock();
Expand Down
18 changes: 18 additions & 0 deletions private/react-native-fantom/repl/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* 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
*/

require('../../../scripts/shared/babelRegister').registerForMonorepo();

const runRepl = require('./runRepl').default;

runRepl().catch(error => {
console.error(error);
process.exit(1);
});
161 changes: 161 additions & 0 deletions private/react-native-fantom/repl/replBundling.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* 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 fs from 'fs';
import path from 'path';

// react-native-github repo root (repl -> ../../.. ).
export const PROJECT_ROOT: string = path.resolve(__dirname, '..', '..', '..');

const RELATIVE_WARMUP_ENTRY = path.join(
'private',
'react-native-fantom',
'runtime',
'ReplEntryPoint.js',
);

const MAX_BUNDLE_FETCH_ATTEMPTS = 10;
const BUNDLE_FETCH_BASE_BACKOFF_MS = 100;
const BUNDLE_FETCH_MAX_BACKOFF_MS = 2_000;

function getMetroPort(): number {
const value = process.env.__FANTOM_METRO_PORT__;
if (value == null) {
throw new Error(
'Could not find Metro server port (process.env.__FANTOM_METRO_PORT__ not set)',
);
}
const port = Number(value);
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid port for Metro server: ${port}`);
}
return port;
}

function getBundleURL(
relativeEntryPath: string,
extraParams: {[string]: string},
): URL {
const requestPath = relativeEntryPath.replace(/\.js$/, '');
const url = new URL(
`http://localhost:${getMetroPort()}/${requestPath}.bundle`,
);
url.searchParams.append('platform', 'android');
url.searchParams.append('dev', 'true');
url.searchParams.append('minify', 'false');
for (const key of Object.keys(extraParams)) {
url.searchParams.append(key, extraParams[key]);
}
return url;
}

async function fetchBundleWithRetry(bundleURL: URL): Promise<string> {
let lastErrorMessage = '';

for (let attempt = 0; attempt < MAX_BUNDLE_FETCH_ATTEMPTS; attempt++) {
if (attempt > 0) {
const backoff = Math.min(
BUNDLE_FETCH_BASE_BACKOFF_MS * 2 ** (attempt - 1),
BUNDLE_FETCH_MAX_BACKOFF_MS,
);
await sleep(backoff);
}

let response;
try {
response = await fetch(bundleURL);
} catch (error: unknown) {
lastErrorMessage = error instanceof Error ? error.message : String(error);
continue;
}

if (response.ok) {
return response.text();
}

const bodyText = await response.text();
const {message, retryable} = parseMetroErrorBody(response.status, bodyText);
lastErrorMessage = message;
if (!retryable) {
throw new Error(`Failed to request bundle from Metro:\n${message}`);
}
}

throw new Error(
`Failed to request bundle from Metro after ${MAX_BUNDLE_FETCH_ATTEMPTS} attempts:\n${lastErrorMessage}`,
);
}

function parseMetroErrorBody(
status: number,
bodyText: string,
): {message: string, retryable: boolean} {
let message = bodyText;
let errorType: ?string;
try {
const parsed = JSON.parse(bodyText);
if (typeof parsed?.message === 'string') {
message = parsed.message;
}
if (typeof parsed?.type === 'string') {
errorType = parsed.type;
}
} catch {
// Not JSON — keep the raw body as the message.
}

const retryable =
status === 404 ||
(status === 500 &&
(errorType === 'UnableToResolveError' ||
errorType === 'ResourceNotFoundError'));

return {message, retryable};
}

/**
* Builds the full warm-up bundle (with the Metro runtime + polyfills) and writes
* it to `outPath`. This is loaded once by the tester binary to set up the
* environment before any REPL input is evaluated.
*/
export async function buildWarmupBundle(outPath: string): Promise<void> {
const code = await fetchBundleWithRetry(
getBundleURL(RELATIVE_WARMUP_ENTRY, {}),
);
await fs.promises.writeFile(outPath, code, 'utf8');
}

/**
* Builds a delta bundle for a single REPL entry: only `__d` registrations (the
* Metro runtime is already loaded from the warm-up bundle) plus the `__r` run
* statement for the entry. Already-registered modules are harmlessly re-declared
* (metro-runtime's `__d` is a no-op for existing module ids).
*/
export async function fetchDeltaBundle(entryPath: string): Promise<string> {
const relativeEntryPath = path.relative(PROJECT_ROOT, entryPath);
const bundleURL = getBundleURL(relativeEntryPath, {
modulesOnly: 'true',
runModule: 'true',
});
const code = await fetchBundleWithRetry(bundleURL);

// Evict Metro's cached dependency graph for this one-off entry to free memory.
try {
await fetch(bundleURL, {method: 'DELETE'});
} catch {
// Best-effort cleanup.
}

return code;
}

async function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
71 changes: 71 additions & 0 deletions private/react-native-fantom/repl/replMetro.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* 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 {Server as HttpServer} from 'http';
import type {Server as HttpsServer} from 'https';

import Metro from 'metro';
import {mergeConfig} from 'metro-config';
import {Server as NetServer} from 'net';
import path from 'path';

let metroServer: ?(HttpServer | HttpsServer) = null;

export async function startMetroServer(): Promise<void> {
process.env.__FANTOM_RUN_ID__ ??= `repl-${Date.now()}`;

if (process.env.__FANTOM_METRO_PORT__ == null) {
process.env.__FANTOM_METRO_PORT__ = String(await findAvailablePort());
}

const port = Number(process.env.__FANTOM_METRO_PORT__);

const baseConfig = await Metro.loadConfig({
config: path.resolve(__dirname, '..', 'config', 'metro.config.js'),
});

// Force the chosen port over the default one baked into the config.
const metroConfig = mergeConfig(baseConfig, {server: {port}});

const {httpServer} = await Metro.runServer(metroConfig, {
waitForBundler: true,
watch: true,
});
metroServer = httpServer;
}

export async function stopMetroServer(): Promise<void> {
const server = metroServer;
metroServer = null;
if (server != null) {
await new Promise<void>(resolve => {
server.close(() => resolve());
});
}
}

async function findAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = new NetServer();
server.listen(0, 'localhost', undefined, () => {
const address = server.address();
const port =
address != null && typeof address === 'object' ? address.port : 0;
server.close(error => {
if (error != null) {
reject(error);
} else {
resolve(port);
}
});
});
server.on('error', reject);
});
}
Loading
Loading