Skip to content
Merged
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
93 changes: 0 additions & 93 deletions flow-typed/npm/react-test-renderer_v16.x.x.js

This file was deleted.

101 changes: 101 additions & 0 deletions flow-typed/react-testing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* 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
* @format
* @oncall react_native
*/

// Subsets of `react-dom/client` and `@testing-library/react` used by Metro.

declare module 'react-dom/client' {
import type {Node} from 'react';

declare export type ErrorInfo = {readonly componentStack?: ?string, ...};

declare export type RootOptions = {
identifierPrefix?: string,
onCaughtError?: (error: unknown, errorInfo: ErrorInfo) => void,
onRecoverableError?: (error: unknown, errorInfo: ErrorInfo) => void,
onUncaughtError?: (error: unknown, errorInfo: ErrorInfo) => void,
};

declare export type Root = {
render(children: Node): void,
unmount(): void,
};

declare module.exports: {
createRoot(
container: Element | DocumentFragment,
options?: RootOptions,
): Root,
hydrateRoot(
container: Element | Document | DocumentFragment,
initialChildren: Node,
options?: RootOptions,
): Root,
};
}

declare module '@testing-library/react/pure' {
import type {ComponentType, Node} from 'react';

declare export type MatcherFunction = (
content: string,
element: Element | null,
) => boolean;

declare export type Matcher = MatcherFunction | RegExp | number | string;

declare export type SelectorMatcherOptions = {
collapseWhitespace?: boolean,
exact?: boolean,
ignore?: boolean | string,
normalizer?: (text: string) => string,
selector?: string,
suggest?: boolean,
trim?: boolean,
};

declare export type BoundQueries = {
getByText: (text: Matcher, options?: SelectorMatcherOptions) => HTMLElement,
queryByText: (
text: Matcher,
options?: SelectorMatcherOptions,
) => HTMLElement | null,
...
};

declare export type RenderOptions = {
baseElement?: HTMLElement,
container?: HTMLElement,
hydrate?: boolean,
wrapper?: ComponentType<{children: Node}>,
};

declare export type RenderResult = {
...BoundQueries,
asFragment: () => DocumentFragment,
baseElement: HTMLElement,
container: HTMLElement,
rerender: (ui: Node) => void,
unmount: () => void,
...
};

declare export var screen: BoundQueries;

declare export function render(
ui: Node,
options?: RenderOptions,
): RenderResult;

declare export function cleanup(): void;

declare export function act(callback: () => void): void;
declare export function act<T>(callback: () => T | Promise<T>): Promise<T>;
}
11 changes: 11 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
* @oncall react_native
*/

const path = require('node:path');

// react-dom may be hoisted away from metro-runtime's react; share one instance.
const reactDir = path.dirname(
require.resolve('react/package.json', {
paths: [path.join(__dirname, 'packages/metro-runtime')],
}),
);

/** @type {import('jest').Config} **/
module.exports = {
filter: '<rootDir>/scripts/jestFilter.js',
Expand All @@ -22,6 +31,8 @@ module.exports = {
},
moduleNameMapper: {
'^prettier$': '<rootDir>/scripts/nativePrettier.js',
'^react$': reactDir,
'^react/(.*)$': `${reactDir}/$1`,
},
testEnvironment: 'node',
testRegex: '/__tests__/.*-test\\.js$',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"istanbul-api": "3.0.0",
"istanbul-lib-coverage": "3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-junit": "^16.0.0",
"jest-watch-typeahead": "^2.2.0",
"jsonc-eslint-parser": "^2.3.0",
Expand Down
6 changes: 4 additions & 2 deletions packages/metro-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@testing-library/dom": "^10.0.0",
"@testing-library/react": "^16.1.0",
"react": "19.3.0",
"react-refresh": "^0.14.0",
"react-test-renderer": "19.3.0"
"react-dom": "19.3.0",
"react-refresh": "^0.14.0"
},
"engines": {
"node": "^22.13.0 || ^24.3.0 || >= 26.0.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,31 @@
*/

import type {DefineFn, RequireFn} from '../require';
import typeof {
act as Act,
render as Render,
screen as Screen,
} from '@testing-library/react/pure';
import typeof * as ReactModule from 'react';
import typeof ReactRefreshRuntime from 'react-refresh/runtime';
import typeof ReactTestRenderer from 'react-test-renderer';

import {transformSync} from '@babel/core';
import fs from 'node:fs';

type RuntimeGlobal = Object;

const runtimeCleanups: Set<() => void> = new Set();

export function cleanupRuntimes(): void {
runtimeCleanups.forEach(cleanup => {
cleanup();
});
runtimeCleanups.clear();
}

/**
* A runtime that combines Metro's module system, a React renderer
* (react-test-renderer) and Fast Refresh.
* (@testing-library/react over react-dom) and Fast Refresh.
*
* The runtime has its own global object and dedicated instances of the relevant
* Metro/React modules, but otherwise runs in the enclosing JS context without
Expand Down Expand Up @@ -61,10 +74,16 @@ export class Runtime {
React: ReactModule;

/**
* The React renderer running in this runtime. Conceptually equivalent to
* require('react-test-renderer').
* Testing Library render bound to this runtime's renderer instance.
* Conceptually equivalent to require('@testing-library/react/pure').render.
*/
render: Render;

/**
* Testing Library screen bound to this runtime's renderer instance.
* Conceptually equivalent to require('@testing-library/react/pure').screen.
*/
renderer: ReactTestRenderer;
screen: Screen;

/**
* Jest mock functions used as event handlers.
Expand All @@ -86,12 +105,19 @@ export class Runtime {

// $FlowFixMe[value-as-type]: react-refresh/runtime is untyped
#reactRefreshRuntime: ReactRefreshRuntime;
#act: Act;
#global: RuntimeGlobal = {};
#globalPrefix: string = '';

constructor() {
// Set up the module system and expose relevant APIs.
createModuleSystem(this.#global, /* __DEV__ */ true, this.#globalPrefix);
// See comment above this function's declaration.
createModuleSystem(
this.#global,
/* __DEV__ */ true,
this.#globalPrefix,
/* window */ undefined,
);
this.define = this.#global[this.#globalPrefix + '__d'];
this.metroRequire = this.#global[this.#globalPrefix + '__r'];
this.registerSegment = this.#global.__registerSegment;
Expand All @@ -109,7 +135,14 @@ export class Runtime {
// NOTE: Strictly speaking, this is an implementation detail of React.
global.__REACT_DEVTOOLS_GLOBAL_HOOK__ =
this.#global.__REACT_DEVTOOLS_GLOBAL_HOOK__;
this.renderer = require('react-test-renderer');
// Loaded while the hook is aliased so the renderer binds to this runtime.
// The pure entry point skips auto-cleanup; tests call cleanupRuntimes().
require('react-dom/client');
const testingLibrary = require('@testing-library/react/pure');
this.render = testingLibrary.render;
this.screen = testingLibrary.screen;
this.#act = testingLibrary.act;
runtimeCleanups.add(testingLibrary.cleanup);
delete global.__REACT_DEVTOOLS_GLOBAL_HOOK__;
});

Expand All @@ -133,7 +166,7 @@ export class Runtime {
this.events.onFullReload('Fast Refresh - Unrecoverable');
return;
}
this.renderer.act(() => {
this.#act(() => {
this.#reactRefreshRuntime.performReactRefresh();
});
this.events.onFastRefresh();
Expand All @@ -156,16 +189,25 @@ const moduleSystemCode = (() => {
}).code;
})();

// Evaluates the transformed require.js with its free variables bound to the
// arguments, installing Metro's module system (`__d`, `__r`,
// `__registerSegment`, ...) on the given global object.
// React Native has no `window` object, but jsdom does, so `window` is bound
// to undefined to simulate React Native. Otherwise require.js'
// `performFullRefresh` would call jsdom's `window.location.reload()`
// instead of `__ReactRefresh.performFullRefresh`.
const createModuleSystem: (
this: any,
RuntimeGlobal,
boolean,
string,
void,
) => unknown =
// eslint-disable-next-line no-new-func
new Function(
'global',
'__DEV__',
'__METRO_GLOBAL_PREFIX__',
'window',
moduleSystemCode,
);
Loading
Loading