/**
* 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
*/
import type {Fiber, FiberRoot} from 'react-reconciler/src/ReactInternalTypes';
import type {
PublicInstance,
Instance,
TextInstance,
} from './ReactFiberConfigTestHost';
import * as React from 'react';
import * as Scheduler from 'scheduler/unstable_mock';
import {
getPublicRootInstance,
createContainer,
updateContainer,
flushSync,
injectIntoDevTools,
batchedUpdates,
} from 'react-reconciler/src/ReactFiberReconciler';
import {findCurrentFiberUsingSlowPath} from 'react-reconciler/src/ReactFiberTreeReflection';
import {
Fragment,
FunctionComponent,
ClassComponent,
HostComponent,
HostHoistable,
HostSingleton,
HostPortal,
HostText,
HostRoot,
ContextConsumer,
ContextProvider,
Mode,
ForwardRef,
Profiler,
MemoComponent,
SimpleMemoComponent,
IncompleteClassComponent,
ScopeComponent,
} from 'react-reconciler/src/ReactWorkTags';
import isArray from 'shared/isArray';
import getComponentNameFromType from 'shared/getComponentNameFromType';
import ReactVersion from 'shared/ReactVersion';
import {checkPropStringCoercion} from 'shared/CheckStringCoercion';
import {getPublicInstance} from './ReactFiberConfigTestHost';
import {ConcurrentRoot, LegacyRoot} from 'react-reconciler/src/ReactRootTags';
import {allowConcurrentByDefault} from 'shared/ReactFeatureFlags';
const act = React.unstable_act;
// TODO: Remove from public bundle
type TestRendererOptions = {
createNodeMock: (element: React$Element<any>) => any,
unstable_isConcurrent: boolean,
unstable_strictMode: boolean,
unstable_concurrentUpdatesByDefault: boolean,
...
};
type ReactTestRendererJSON = {
type: string,
props: {[propName: string]: any, ...},
children: null | Array<ReactTestRendererNode>,
$$typeof?: symbol, // Optional because we add it with defineProperty().
};
type ReactTestRendererNode = ReactTestRendererJSON | string;
type FindOptions = {
// performs a "greedy" search: if a matching node is found, will continue
// to search within the matching node's children. (default: true)
deep?: boolean,
};
export type Predicate = (node: ReactTestInstance) => ?boolean;
const defaultTestOptions = {
createNodeMock: function () {
return null;
},
};
function toJSON(inst: Instance | TextInstance): ReactTestRendererNode | null {
if (inst.isHidden) {
// Omit timed out children from output entirely. This seems like the least
// surprising behavior. We could perhaps add a separate API that includes
// them, if it turns out people need it.
return null;
}
switch (inst.tag) {
case 'TEXT':
return inst.text;
case 'INSTANCE': {
/* eslint-disable no-unused-vars */
// We don't include the `children` prop in JSON.
// Instead, we will include the actual rendered children.
const {children, ...props} = inst.props;
/* eslint-enable */
let renderedChildren = null;
if (inst.children && inst.children.length) {
for (let i = 0; i < inst.children.length; i++) {
const renderedChild = toJSON(inst.children[i]);
if (renderedChild !== null) {
if (renderedChildren === null) {
renderedChildren = [renderedChild];
} else {
renderedChildren.push(renderedChild);
}
}
}
}
const json: ReactTestRendererJSON = {
type: inst.type,
props: props,
children: renderedChildren,
};
Object.defineProperty(json, '$$typeof', {
value: Symbol.for('react.test.json'),
});
return json;
}
default:
throw new Error(`Unexpected node type in toJSON: ${inst.tag}`);
}
}
function childrenToTree(node: null | Fiber) {
if (!node) {
return null;
}
const children = nodeAndSiblingsArray(node);
if (children.length === 0) {
return null;
} else if (children.length === 1) {
return toTree(children[0]);
}
return flatten(children.map(toTree));
}
// $FlowFixMe[missing-local-annot]
function nodeAndSiblingsArray(nodeWithSibling) {
const array = [];
let node = nodeWithSibling;
while (node != null) {
array.push(node);
node = node.sibling;
}
return array;
}
// $FlowFixMe[missing-local-annot]
function flatten(arr) {
const result = [];
const stack = [{i: 0, array: arr}];
while (stack.length) {
const n = stack.pop();
while (n.i < n.array.length) {
const el = n.array[n.i];
n.i += 1;
if (isArray(el)) {
stack.push(n);
stack.push({i: 0, array: el});
break;
}
result.push(el);
}
}
return result;
}
function toTree(node: null | Fiber): $FlowFixMe {
if (node == null) {
return null;
}
switch (node.tag) {
case HostRoot:
return childrenToTree(node.child);
case HostPortal:
return childrenToTree(node.child);
case ClassComponent:
return {
nodeType: 'component',
type: node.type,
props: {...node.memoizedProps},
instance: node.stateNode,
rendered: childrenToTree(node.child),
};
case FunctionComponent:
case SimpleMemoComponent:
return {
nodeType: 'component',
type: node.type,
props: {...node.memoizedProps},
instance: null,
rendered: childrenToTree(node.child),
};
case HostHoistable:
case HostSingleton:
case HostComponent: {
return {
nodeType: 'host',
type: node.type,
props: {...node.memoizedProps},
instance: null, // TODO: use createNodeMock here somehow?
rendered: flatten(nodeAndSiblingsArray(node.child).map(toTree)),
};
}
case HostText:
return node.stateNode.text;
case Fragment:
case ContextProvider:
case ContextConsumer:
case Mode:
case Profiler:
case ForwardRef:
case MemoComponent:
case IncompleteClassComponent:
case ScopeComponent:
return childrenToTree(node.child);
default:
throw new Error(
`toTree() does not yet know how to handle nodes with tag=${node.tag}`,
);
}
}
const validWrapperTypes = new Set([
FunctionComponent,
ClassComponent,
HostComponent,
ForwardRef,
MemoComponent,
SimpleMemoComponent,
// Normally skipped, but used when there's more than one root child.
HostRoot,
]);
function getChildren(parent: Fiber) {
const children = [];
const startingNode = parent;
let node: Fiber = startingNode;
if (node.child === null) {
return children;
}
node.child.return = node;
node = node.child;
outer: while (true) {
let descend = false;
if (validWrapperTypes.has(node.tag)) {
children.push(wrapFiber(node));
} else if (node.tag === HostText) {
if (__DEV__) {
checkPropStringCoercion(node.memoizedProps, 'memoizedProps');
}
children.push('' + node.memoizedProps);
} else {
descend = true;
}
if (descend && node.child !== null) {
node.child.return = node;
node = node.child;
continue;
}
while (node.sibling === null) {
if (node.return === startingNode) {
break outer;
}
node = (node.return: any);
}
(node.sibling: any).return = node.return;
node = (node.sibling: any);
}
return children;
}
class ReactTestInstance {
_fiber: Fiber;
_currentFiber(): Fiber {
// Throws if this component has been unmounted.
const fiber = findCurrentFiberUsingSlowPath(this._fiber);
if (fiber === null) {
throw new Error(
"Can't read from currently-mounting component. This error is likely " +
'caused by a bug in React. Please file an issue.',
);
}
return fiber;
}
constructor(fiber: Fiber) {
if (!validWrapperTypes.has(fiber.tag)) {
throw new Error(
`Unexpected object passed to ReactTestInstance constructor (tag: ${fiber.tag}). ` +
'This is probably a bug in React.',
);
}
this._fiber = fiber;
}
get instance(): $FlowFixMe {
const tag = this._fiber.tag;
if (
tag === HostComponent ||
tag === HostHoistable ||
tag === HostSingleton
) {
return getPublicInstance(this._fiber.stateNode);
} else {
return this._fiber.stateNode;
}
}
get type(): any {
return this._fiber.type;
}
get props(): Object {
return this._currentFiber().memoizedProps;
}
get parent(): ?ReactTestInstance {
let parent = this._fiber.return;
while (parent !== null) {
if (validWrapperTypes.has(parent.tag)) {
if (parent.tag === HostRoot) {
// Special case: we only "materialize" instances for roots
// if they have more than a single child. So we'll check that now.
if (getChildren(parent).length < 2) {
return null;
}
}
return wrapFiber(parent);
}
parent = parent.return;
}
return null;
}
get children(): Array<ReactTestInstance | string> {
return getChildren(this._currentFiber());
}
// Custom search functions
find(predicate: Predicate): ReactTestInstance {
return expectOne(
this.findAll(predicate, {deep: false}),
`matching custom predicate: ${predicate.toString()}`,
);
}
findByType(type: any): ReactTestInstance {
return expectOne(
this.findAllByType(type, {deep: false}),
`with node type: "${getComponentNameFromType(type) || 'Unknown'}"`,
);
}
findByProps(props: Object): ReactTestInstance {
return expectOne(
this.findAllByProps(props, {deep: false}),
`with props: ${JSON.stringify(props)}`,
);
}
findAll(
predicate: Predicate,
options: ?FindOptions = null,
): Array<ReactTestInstance> {
return findAll(this, predicate, options);
}
findAllByType(
type: any,
options: ?FindOptions = null,
): Array<ReactTestInstance> {
return findAll(this, node => node.type === type, options);
}
findAllByProps(
props: Object,
options: ?FindOptions = null,
): Array<ReactTestInstance> {
return findAll(
this,
node => node.props && propsMatch(node.props, props),
options,
);
}
}
function findAll(
root: ReactTestInstance,
predicate: Predicate,
options: ?FindOptions,
): Array<ReactTestInstance> {
const deep = options ? options.deep : true;
const results = [];
if (predicate(root)) {
results.push(root);
if (!deep) {
return results;
}
}
root.children.forEach(child => {
if (typeof child === 'string') {
return;
}
results.push(...findAll(child, predicate, options));
});
return results;
}
function expectOne(
all: Array<ReactTestInstance>,
message: string,
): ReactTestInstance {
if (all.length === 1) {
return all[0];
}
const prefix =
all.length === 0
? 'No instances found '
: `Expected 1 but found ${all.length} instances `;
throw new Error(prefix + message);
}
function propsMatch(props: Object, filter: Object): boolean {
for (const key in filter) {
if (props[key] !== filter[key]) {
return false;
}
}
return true;
}
// $FlowFixMe[missing-local-annot]
function onRecoverableError(error) {
// TODO: Expose onRecoverableError option to userspace
// eslint-disable-next-line react-internal/no-production-logging, react-internal/warning-args
console.error(error);
}
function create(
element: React$Element<any>,
options: TestRendererOptions,
): {
_Scheduler: typeof Scheduler,
root: void,
toJSON(): Array<ReactTestRendererNode> | ReactTestRendererNode | null,
toTree(): mixed,
update(newElement: React$Element<any>): any,
unmount(): void,
getInstance(): React$Component<any, any> | PublicInstance | null,
unstable_flushSync: typeof flushSync,
} {
let createNodeMock = defaultTestOptions.createNodeMock;
let isConcurrent = false;
let isStrictMode = false;
let concurrentUpdatesByDefault = null;
if (typeof options === 'object' && options !== null) {
if (typeof options.createNodeMock === 'function') {
// $FlowFixMe[incompatible-type] found when upgrading Flow
createNodeMock = options.createNodeMock;
}
if (options.unstable_isConcurrent === true) {
isConcurrent = true;
}
if (options.unstable_strictMode === true) {
isStrictMode = true;
}
if (allowConcurrentByDefault) {
if (options.unstable_concurrentUpdatesByDefault !== undefined) {
concurrentUpdatesByDefault =
options.unstable_concurrentUpdatesByDefault;
}
}
}
let container = {
children: ([]: Array<Instance | TextInstance>),
createNodeMock,
tag: 'CONTAINER',
};
let root: FiberRoot | null = createContainer(
container,
isConcurrent ? ConcurrentRoot : LegacyRoot,
null,
isStrictMode,
concurrentUpdatesByDefault,
'',
onRecoverableError,
null,
);
if (root == null) {
throw new Error('something went wrong');
}
updateContainer(element, root, null, null);
const entry = {
_Scheduler: Scheduler,
root: undefined, // makes flow happy
// we define a 'getter' for 'root' below using 'Object.defineProperty'
toJSON(): Array<ReactTestRendererNode> | ReactTestRendererNode | null {
if (root == null || root.current == null || container == null) {
return null;
}
if (container.children.length === 0) {
return null;
}
if (container.children.length === 1) {
return toJSON(container.children[0]);
}
if (
container.children.length === 2 &&
container.children[0].isHidden === true &&
container.children[1].isHidden === false
) {
// Omit timed out children from output entirely, including the fact that we
// temporarily wrap fallback and timed out children in an array.
return toJSON(container.children[1]);
}
let renderedChildren = null;
if (container.children && container.children.length) {
for (let i = 0; i < container.children.length; i++) {
const renderedChild = toJSON(container.children[i]);
if (renderedChild !== null) {
if (renderedChildren === null) {
renderedChildren = [renderedChild];
} else {
renderedChildren.push(renderedChild);
}
}
}
}
return renderedChildren;
},
toTree() {
if (root == null || root.current == null) {
return null;
}
return toTree(root.current);
},
update(newElement: React$Element<any>): number | void {
if (root == null || root.current == null) {
return;
}
updateContainer(newElement, root, null, null);
},
unmount() {
if (root == null || root.current == null) {
return;
}
updateContainer(null, root, null, null);
// $FlowFixMe[incompatible-type] found when upgrading Flow
container = null;
root = null;
},
getInstance() {
if (root == null || root.current == null) {
return null;
}
return getPublicRootInstance(root);
},
unstable_flushSync: flushSync,
};
Object.defineProperty(
entry,
'root',
({
configurable: true,
enumerable: true,
get: function () {
if (root === null) {
throw new Error("Can't access .root on unmounted test renderer");
}
const children = getChildren(root.current);
if (children.length === 0) {
throw new Error("Can't access .root on unmounted test renderer");
} else if (children.length === 1) {
// Normally, we skip the root and just give you the child.
return children[0];
} else {
// However, we give you the root if there's more than one root child.
// We could make this the behavior for all cases but it would be a breaking change.
// $FlowFixMe[incompatible-use] found when upgrading Flow
return wrapFiber(root.current);
}
},
}: Object),
);
return entry;
}
const fiberToWrapper = new WeakMap<Fiber, ReactTestInstance>();
function wrapFiber(fiber: Fiber): ReactTestInstance {
let wrapper = fiberToWrapper.get(fiber);
if (wrapper === undefined && fiber.alternate !== null) {
wrapper = fiberToWrapper.get(fiber.alternate);
}
if (wrapper === undefined) {
wrapper = new ReactTestInstance(fiber);
fiberToWrapper.set(fiber, wrapper);
}
return wrapper;
}
// Enable ReactTestRenderer to be used to test DevTools integration.
injectIntoDevTools({
findFiberByHostInstance: (() => {
throw new Error('TestRenderer does not support findFiberByHostInstance()');
}: any),
bundleType: __DEV__ ? 1 : 0,
version: ReactVersion,
rendererPackageName: 'react-test-renderer',
});
export {
Scheduler as _Scheduler,
create,
/* eslint-disable-next-line camelcase */
batchedUpdates as unstable_batchedUpdates,
act,
};