/**
 * 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
 */

/**
 * This is a renderer of React that doesn't have a render target output.
 * It is useful to demonstrate the internals of the reconciler in isolation
 * and for testing semantics of reconciliation separate from the host
 * environment.
 */

import type {
  Fiber,
  TransitionTracingCallbacks,
} from 'react-reconciler/src/ReactInternalTypes';
import type {UpdateQueue} from 'react-reconciler/src/ReactFiberClassUpdateQueue';
import type {ReactNodeList} from 'shared/ReactTypes';
import type {RootTag} from 'react-reconciler/src/ReactRootTags';

import * as Scheduler from 'scheduler/unstable_mock';
import {REACT_FRAGMENT_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
import isArray from 'shared/isArray';
import {checkPropStringCoercion} from 'shared/CheckStringCoercion';
import {
  DefaultEventPriority,
  IdleEventPriority,
  ConcurrentRoot,
  LegacyRoot,
} from 'react-reconciler/constants';

type Container = {
  rootID: string,
  children: Array<Instance | TextInstance>,
  pendingChildren: Array<Instance | TextInstance>,
  ...
};
type Props = {
  prop: any,
  hidden: boolean,
  children?: mixed,
  bottom?: null | number,
  left?: null | number,
  right?: null | number,
  top?: null | number,
  src?: string,
  ...
};
type Instance = {
  type: string,
  id: number,
  parent: number,
  children: Array<Instance | TextInstance>,
  text: string | null,
  prop: any,
  hidden: boolean,
  context: HostContext,
};
type TextInstance = {
  text: string,
  id: number,
  parent: number,
  hidden: boolean,
  context: HostContext,
};
type HostContext = Object;
type CreateRootOptions = {
  unstable_transitionCallbacks?: TransitionTracingCallbacks,
  ...
};

type SuspenseyCommitSubscription = {
  pendingCount: number,
  commit: null | (() => void),
};

export type TransitionStatus = mixed;

const NO_CONTEXT = {};
const UPPERCASE_CONTEXT = {};
if (__DEV__) {
  Object.freeze(NO_CONTEXT);
}

function createReactNoop(reconciler: Function, useMutation: boolean) {
  let instanceCounter = 0;
  let hostUpdateCounter = 0;
  let hostCloneCounter = 0;

  function appendChildToContainerOrInstance(
    parentInstance: Container | Instance,
    child: Instance | TextInstance,
  ): void {
    const prevParent = child.parent;
    if (prevParent !== -1 && prevParent !== parentInstance.id) {
      throw new Error('Reparenting is not allowed');
    }
    child.parent = parentInstance.id;
    const index = parentInstance.children.indexOf(child);
    if (index !== -1) {
      parentInstance.children.splice(index, 1);
    }
    parentInstance.children.push(child);
  }

  function appendChildToContainer(
    parentInstance: Container,
    child: Instance | TextInstance,
  ): void {
    if (typeof parentInstance.rootID !== 'string') {
      // Some calls to this aren't typesafe.
      // This helps surface mistakes in tests.
      throw new Error(
        'appendChildToContainer() first argument is not a container.',
      );
    }
    appendChildToContainerOrInstance(parentInstance, child);
  }

  function appendChild(
    parentInstance: Instance,
    child: Instance | TextInstance,
  ): void {
    if (typeof (parentInstance: any).rootID === 'string') {
      // Some calls to this aren't typesafe.
      // This helps surface mistakes in tests.
      throw new Error('appendChild() first argument is not an instance.');
    }
    appendChildToContainerOrInstance(parentInstance, child);
  }

  function insertInContainerOrInstanceBefore(
    parentInstance: Container | Instance,
    child: Instance | TextInstance,
    beforeChild: Instance | TextInstance,
  ): void {
    const index = parentInstance.children.indexOf(child);
    if (index !== -1) {
      parentInstance.children.splice(index, 1);
    }
    const beforeIndex = parentInstance.children.indexOf(beforeChild);
    if (beforeIndex === -1) {
      throw new Error('This child does not exist.');
    }
    parentInstance.children.splice(beforeIndex, 0, child);
  }

  function insertInContainerBefore(
    parentInstance: Container,
    child: Instance | TextInstance,
    beforeChild: Instance | TextInstance,
  ) {
    if (typeof parentInstance.rootID !== 'string') {
      // Some calls to this aren't typesafe.
      // This helps surface mistakes in tests.
      throw new Error(
        'insertInContainerBefore() first argument is not a container.',
      );
    }
    insertInContainerOrInstanceBefore(parentInstance, child, beforeChild);
  }

  function insertBefore(
    parentInstance: Instance,
    child: Instance | TextInstance,
    beforeChild: Instance | TextInstance,
  ) {
    if (typeof (parentInstance: any).rootID === 'string') {
      // Some calls to this aren't typesafe.
      // This helps surface mistakes in tests.
      throw new Error('insertBefore() first argument is not an instance.');
    }
    insertInContainerOrInstanceBefore(parentInstance, child, beforeChild);
  }

  function clearContainer(container: Container): void {
    container.children.splice(0);
  }

  function removeChildFromContainerOrInstance(
    parentInstance: Container | Instance,
    child: Instance | TextInstance,
  ): void {
    const index = parentInstance.children.indexOf(child);
    if (index === -1) {
      throw new Error('This child does not exist.');
    }
    parentInstance.children.splice(index, 1);
  }

  function removeChildFromContainer(
    parentInstance: Container,
    child: Instance | TextInstance,
  ): void {
    if (typeof parentInstance.rootID !== 'string') {
      // Some calls to this aren't typesafe.
      // This helps surface mistakes in tests.
      throw new Error(
        'removeChildFromContainer() first argument is not a container.',
      );
    }
    removeChildFromContainerOrInstance(parentInstance, child);
  }

  function removeChild(
    parentInstance: Instance,
    child: Instance | TextInstance,
  ): void {
    if (typeof (parentInstance: any).rootID === 'string') {
      // Some calls to this aren't typesafe.
      // This helps surface mistakes in tests.
      throw new Error('removeChild() first argument is not an instance.');
    }
    removeChildFromContainerOrInstance(parentInstance, child);
  }

  function cloneInstance(
    instance: Instance,
    type: string,
    oldProps: Props,
    newProps: Props,
    keepChildren: boolean,
    children: ?$ReadOnlyArray<Instance>,
  ): Instance {
    if (__DEV__) {
      checkPropStringCoercion(newProps.children, 'children');
    }
    const clone = {
      id: instance.id,
      type: type,
      parent: instance.parent,
      children: keepChildren ? instance.children : children ?? [],
      text: shouldSetTextContent(type, newProps)
        ? computeText((newProps.children: any) + '', instance.context)
        : null,
      prop: newProps.prop,
      hidden: !!newProps.hidden,
      context: instance.context,
    };

    if (type === 'suspensey-thing' && typeof newProps.src === 'string') {
      clone.src = newProps.src;
    }

    Object.defineProperty(clone, 'id', {
      value: clone.id,
      enumerable: false,
    });
    Object.defineProperty(clone, 'parent', {
      value: clone.parent,
      enumerable: false,
    });
    Object.defineProperty(clone, 'text', {
      value: clone.text,
      enumerable: false,
    });
    Object.defineProperty(clone, 'context', {
      value: clone.context,
      enumerable: false,
    });
    hostCloneCounter++;
    return clone;
  }

  function shouldSetTextContent(type: string, props: Props): boolean {
    if (type === 'errorInBeginPhase') {
      throw new Error('Error in host config.');
    }
    return (
      typeof props.children === 'string' || typeof props.children === 'number'
    );
  }

  function computeText(rawText, hostContext) {
    return hostContext === UPPERCASE_CONTEXT ? rawText.toUpperCase() : rawText;
  }

  type SuspenseyThingRecord = {
    status: 'pending' | 'fulfilled',
    subscriptions: Array<SuspenseyCommitSubscription> | null,
  };

  let suspenseyThingCache: Map<
    SuspenseyThingRecord,
    'pending' | 'fulfilled',
  > | null = null;

  // Represents a subscription for all the suspensey things that block a
  // particular commit. Once they've all loaded, the commit phase can proceed.
  let suspenseyCommitSubscription: SuspenseyCommitSubscription | null = null;

  function startSuspendingCommit(): void {
    // This is where we might suspend on things that aren't associated with a
    // particular node, like document.fonts.ready.
    suspenseyCommitSubscription = null;
  }

  function suspendInstance(type: string, props: Props): void {
    const src = props.src;
    if (type === 'suspensey-thing' && typeof src === 'string') {
      // Attach a listener to the suspensey thing and create a subscription
      // object that uses reference counting to track when all the suspensey
      // things have loaded.
      const record = suspenseyThingCache.get(src);
      if (record === undefined) {
        throw new Error('Could not find record for key.');
      }
      if (record.status === 'fulfilled') {
        // Already loaded.
      } else if (record.status === 'pending') {
        if (suspenseyCommitSubscription === null) {
          suspenseyCommitSubscription = {
            pendingCount: 1,
            commit: null,
          };
        } else {
          suspenseyCommitSubscription.pendingCount++;
        }
        // Stash the subscription on the record. In `resolveSuspenseyThing`,
        // we'll use this fire the commit once all the things have loaded.
        if (record.subscriptions === null) {
          record.subscriptions = [];
        }
        record.subscriptions.push(suspenseyCommitSubscription);
      }
    } else {
      throw new Error(
        'Did not expect this host component to be visited when suspending ' +
          'the commit. Did you check the SuspendCommit flag?',
      );
    }
  }

  function waitForCommitToBeReady():
    | ((commit: () => mixed) => () => void)
    | null {
    const subscription = suspenseyCommitSubscription;
    if (subscription !== null) {
      suspenseyCommitSubscription = null;
      return (commit: () => void) => {
        subscription.commit = commit;
        const cancelCommit = () => {
          subscription.commit = null;
        };
        return cancelCommit;
      };
    }
    return null;
  }

  const sharedHostConfig = {
    supportsSingletons: false,

    getRootHostContext() {
      return NO_CONTEXT;
    },

    getChildHostContext(parentHostContext: HostContext, type: string) {
      if (type === 'offscreen') {
        return parentHostContext;
      }
      if (type === 'uppercase') {
        return UPPERCASE_CONTEXT;
      }
      return NO_CONTEXT;
    },

    getPublicInstance(instance) {
      return instance;
    },

    createInstance(
      type: string,
      props: Props,
      rootContainerInstance: Container,
      hostContext: HostContext,
      internalInstanceHandle: Object,
    ): Instance {
      if (type === 'errorInCompletePhase') {
        throw new Error('Error in host config.');
      }
      if (__DEV__) {
        // The `if` statement here prevents auto-disabling of the safe coercion
        // ESLint rule, so we must manually disable it below.
        if (shouldSetTextContent(type, props)) {
          checkPropStringCoercion(props.children, 'children');
        }
      }
      const inst = {
        id: instanceCounter++,
        type: type,
        children: [],
        parent: -1,
        text: shouldSetTextContent(type, props)
          ? // eslint-disable-next-line react-internal/safe-string-coercion
            computeText((props.children: any) + '', hostContext)
          : null,
        prop: props.prop,
        hidden: !!props.hidden,
        context: hostContext,
      };

      if (type === 'suspensey-thing' && typeof props.src === 'string') {
        inst.src = props.src;
      }

      // Hide from unit tests
      Object.defineProperty(inst, 'id', {value: inst.id, enumerable: false});
      Object.defineProperty(inst, 'parent', {
        value: inst.parent,
        enumerable: false,
      });
      Object.defineProperty(inst, 'text', {
        value: inst.text,
        enumerable: false,
      });
      Object.defineProperty(inst, 'context', {
        value: inst.context,
        enumerable: false,
      });
      Object.defineProperty(inst, 'fiber', {
        value: internalInstanceHandle,
        enumerable: false,
      });
      return inst;
    },

    appendInitialChild(
      parentInstance: Instance,
      child: Instance | TextInstance,
    ): void {
      const prevParent = child.parent;
      if (prevParent !== -1 && prevParent !== parentInstance.id) {
        throw new Error('Reparenting is not allowed');
      }
      child.parent = parentInstance.id;
      parentInstance.children.push(child);
    },

    finalizeInitialChildren(
      domElement: Instance,
      type: string,
      props: Props,
    ): boolean {
      return false;
    },

    shouldSetTextContent,

    createTextInstance(
      text: string,
      rootContainerInstance: Container,
      hostContext: Object,
      internalInstanceHandle: Object,
    ): TextInstance {
      if (hostContext === UPPERCASE_CONTEXT) {
        text = text.toUpperCase();
      }
      const inst = {
        text: text,
        id: instanceCounter++,
        parent: -1,
        hidden: false,
        context: hostContext,
      };
      // Hide from unit tests
      Object.defineProperty(inst, 'id', {value: inst.id, enumerable: false});
      Object.defineProperty(inst, 'parent', {
        value: inst.parent,
        enumerable: false,
      });
      Object.defineProperty(inst, 'context', {
        value: inst.context,
        enumerable: false,
      });
      return inst;
    },

    scheduleTimeout: setTimeout,
    cancelTimeout: clearTimeout,
    noTimeout: -1,

    supportsMicrotasks: true,
    scheduleMicrotask:
      typeof queueMicrotask === 'function'
        ? queueMicrotask
        : typeof Promise !== 'undefined'
        ? callback =>
            Promise.resolve(null)
              .then(callback)
              .catch(error => {
                setTimeout(() => {
                  throw error;
                });
              })
        : setTimeout,

    prepareForCommit(): null | Object {
      return null;
    },

    resetAfterCommit(): void {},

    getCurrentEventPriority() {
      return currentEventPriority;
    },

    shouldAttemptEagerTransition(): boolean {
      return false;
    },

    now: Scheduler.unstable_now,

    isPrimaryRenderer: true,
    warnsIfNotActing: true,
    supportsHydration: false,

    getInstanceFromNode() {
      throw new Error('Not yet implemented.');
    },

    beforeActiveInstanceBlur() {
      // NO-OP
    },

    afterActiveInstanceBlur() {
      // NO-OP
    },

    preparePortalMount() {
      // NO-OP
    },

    prepareScopeUpdate() {},

    getInstanceFromScope() {
      throw new Error('Not yet implemented.');
    },

    detachDeletedInstance() {},

    logRecoverableError() {
      // no-op
    },

    requestPostPaintCallback(callback) {
      const endTime = Scheduler.unstable_now();
      callback(endTime);
    },

    maySuspendCommit(type: string, props: Props): boolean {
      // Asks whether it's possible for this combination of type and props
      // to ever need to suspend. This is different from asking whether it's
      // currently ready because even if it's ready now, it might get purged
      // from the cache later.
      return type === 'suspensey-thing' && typeof props.src === 'string';
    },

    mayResourceSuspendCommit(resource: mixed): boolean {
      throw new Error(
        'Resources are not implemented for React Noop yet. This method should not be called',
      );
    },

    preloadInstance(type: string, props: Props): boolean {
      if (type !== 'suspensey-thing' || typeof props.src !== 'string') {
        throw new Error('Attempted to preload unexpected instance: ' + type);
      }

      // In addition to preloading an instance, this method asks whether the
      // instance is ready to be committed. If it's not, React may yield to the
      // main thread and ask again. It's possible a load event will fire in
      // between, in which case we can avoid showing a fallback.
      if (suspenseyThingCache === null) {
        suspenseyThingCache = new Map();
      }
      const record = suspenseyThingCache.get(props.src);
      if (record === undefined) {
        const newRecord: SuspenseyThingRecord = {
          status: 'pending',
          subscriptions: null,
        };
        suspenseyThingCache.set(props.src, newRecord);
        const onLoadStart = props.onLoadStart;
        if (typeof onLoadStart === 'function') {
          onLoadStart();
        }
        return false;
      } else {
        // If this is false, React will trigger a fallback, if needed.
        return record.status === 'fulfilled';
      }
    },

    preloadResource(resource: mixed): boolean {
      throw new Error(
        'Resources are not implemented for React Noop yet. This method should not be called',
      );
    },

    startSuspendingCommit,
    suspendInstance,

    suspendResource(resource: mixed): void {
      throw new Error(
        'Resources are not implemented for React Noop yet. This method should not be called',
      );
    },

    waitForCommitToBeReady,

    NotPendingTransition: (null: TransitionStatus),
  };

  const hostConfig = useMutation
    ? {
        ...sharedHostConfig,

        supportsMutation: true,
        supportsPersistence: false,

        commitMount(instance: Instance, type: string, newProps: Props): void {
          // Noop
        },

        commitUpdate(
          instance: Instance,
          updatePayload: Object,
          type: string,
          oldProps: Props,
          newProps: Props,
        ): void {
          if (oldProps === null) {
            throw new Error('Should have old props');
          }
          hostUpdateCounter++;
          instance.prop = newProps.prop;
          instance.hidden = !!newProps.hidden;

          if (type === 'suspensey-thing' && typeof newProps.src === 'string') {
            instance.src = newProps.src;
          }

          if (shouldSetTextContent(type, newProps)) {
            if (__DEV__) {
              checkPropStringCoercion(newProps.children, 'children');
            }
            instance.text = computeText(
              (newProps.children: any) + '',
              instance.context,
            );
          }
        },

        commitTextUpdate(
          textInstance: TextInstance,
          oldText: string,
          newText: string,
        ): void {
          hostUpdateCounter++;
          textInstance.text = computeText(newText, textInstance.context);
        },

        appendChild,
        appendChildToContainer,
        insertBefore,
        insertInContainerBefore,
        removeChild,
        removeChildFromContainer,
        clearContainer,

        hideInstance(instance: Instance): void {
          instance.hidden = true;
        },

        hideTextInstance(textInstance: TextInstance): void {
          textInstance.hidden = true;
        },

        unhideInstance(instance: Instance, props: Props): void {
          if (!props.hidden) {
            instance.hidden = false;
          }
        },

        unhideTextInstance(textInstance: TextInstance, text: string): void {
          textInstance.hidden = false;
        },

        resetTextContent(instance: Instance): void {
          instance.text = null;
        },
      }
    : {
        ...sharedHostConfig,
        supportsMutation: false,
        supportsPersistence: true,

        cloneInstance,
        clearContainer,

        createContainerChildSet(): Array<Instance | TextInstance> {
          return [];
        },

        appendChildToContainerChildSet(
          childSet: Array<Instance | TextInstance>,
          child: Instance | TextInstance,
        ): void {
          childSet.push(child);
        },

        finalizeContainerChildren(
          container: Container,
          newChildren: Array<Instance | TextInstance>,
        ): void {
          container.pendingChildren = newChildren;
          if (
            newChildren.length === 1 &&
            newChildren[0].text === 'Error when completing root'
          ) {
            // Trigger an error for testing purposes
            throw Error('Error when completing root');
          }
        },

        replaceContainerChildren(
          container: Container,
          newChildren: Array<Instance | TextInstance>,
        ): void {
          container.children = newChildren;
        },

        cloneHiddenInstance(
          instance: Instance,
          type: string,
          props: Props,
        ): Instance {
          const clone = cloneInstance(instance, type, props, props, true, null);
          clone.hidden = true;
          return clone;
        },

        cloneHiddenTextInstance(
          instance: TextInstance,
          text: string,
        ): TextInstance {
          const clone = {
            text: instance.text,
            id: instance.id,
            parent: instance.parent,
            hidden: true,
            context: instance.context,
          };
          // Hide from unit tests
          Object.defineProperty(clone, 'id', {
            value: clone.id,
            enumerable: false,
          });
          Object.defineProperty(clone, 'parent', {
            value: clone.parent,
            enumerable: false,
          });
          Object.defineProperty(clone, 'context', {
            value: clone.context,
            enumerable: false,
          });
          return clone;
        },
      };

  const NoopRenderer = reconciler(hostConfig);

  const rootContainers = new Map();
  const roots = new Map();
  const DEFAULT_ROOT_ID = '<default>';

  let currentEventPriority = DefaultEventPriority;

  function childToJSX(child, text) {
    if (text !== null) {
      return text;
    }
    if (child === null) {
      return null;
    }
    if (typeof child === 'string') {
      return child;
    }
    if (isArray(child)) {
      if (child.length === 0) {
        return null;
      }
      if (child.length === 1) {
        return childToJSX(child[0], null);
      }
      const children = child.map(c => childToJSX(c, null));
      if (children.every(c => typeof c === 'string' || typeof c === 'number')) {
        return children.join('');
      }
      return children;
    }
    if (isArray(child.children)) {
      // This is an instance.
      const instance: Instance = (child: any);
      const children = childToJSX(instance.children, instance.text);
      const props = ({prop: instance.prop}: any);
      if (instance.hidden) {
        props.hidden = true;
      }
      if (instance.src) {
        props.src = instance.src;
      }
      if (children !== null) {
        props.children = children;
      }
      return {
        $$typeof: REACT_ELEMENT_TYPE,
        type: instance.type,
        key: null,
        ref: null,
        props: props,
        _owner: null,
        _store: __DEV__ ? {} : undefined,
      };
    }
    // This is a text instance
    const textInstance: TextInstance = (child: any);
    if (textInstance.hidden) {
      return '';
    }
    return textInstance.text;
  }

  function getChildren(root) {
    if (root) {
      return root.children;
    } else {
      return null;
    }
  }

  function getPendingChildren(root) {
    if (root) {
      return root.children;
    } else {
      return null;
    }
  }

  function getChildrenAsJSX(root) {
    const children = childToJSX(getChildren(root), null);
    if (children === null) {
      return null;
    }
    if (isArray(children)) {
      return {
        $$typeof: REACT_ELEMENT_TYPE,
        type: REACT_FRAGMENT_TYPE,
        key: null,
        ref: null,
        props: {children},
        _owner: null,
        _store: __DEV__ ? {} : undefined,
      };
    }
    return children;
  }

  function getPendingChildrenAsJSX(root) {
    const children = childToJSX(getChildren(root), null);
    if (children === null) {
      return null;
    }
    if (isArray(children)) {
      return {
        $$typeof: REACT_ELEMENT_TYPE,
        type: REACT_FRAGMENT_TYPE,
        key: null,
        ref: null,
        props: {children},
        _owner: null,
        _store: __DEV__ ? {} : undefined,
      };
    }
    return children;
  }

  function flushSync<R>(fn: () => R): R {
    if (__DEV__) {
      if (NoopRenderer.isAlreadyRendering()) {
        console.error(
          'flushSync was called from inside a lifecycle method. React cannot ' +
            'flush when React is already rendering. Consider moving this call to ' +
            'a scheduler task or micro task.',
        );
      }
    }
    return NoopRenderer.flushSync(fn);
  }

  function onRecoverableError(error) {
    // TODO: Turn this on once tests are fixed
    // eslint-disable-next-line react-internal/no-production-logging, react-internal/warning-args
    // console.error(error);
  }

  let idCounter = 0;

  const ReactNoop = {
    _Scheduler: Scheduler,

    getChildren(rootID: string = DEFAULT_ROOT_ID) {
      throw new Error(
        'No longer supported due to bad performance when used with `expect()`. ' +
          'Use `ReactNoop.getChildrenAsJSX()` instead or, if you really need to, `dangerouslyGetChildren` after you carefully considered the warning in its JSDOC.',
      );
    },

    getPendingChildren(rootID: string = DEFAULT_ROOT_ID) {
      throw new Error(
        'No longer supported due to bad performance when used with `expect()`. ' +
          'Use `ReactNoop.getPendingChildrenAsJSX()` instead or, if you really need to, `dangerouslyGetPendingChildren` after you carefully considered the warning in its JSDOC.',
      );
    },

    /**
     * Prefer using `getChildrenAsJSX`.
     * Using the returned children in `.toEqual` has very poor performance on mismatch due to deep equality checking of fiber structures.
     * Make sure you deeply remove enumerable properties before passing it to `.toEqual`, or, better, use `getChildrenAsJSX` or `toMatchRenderedOutput`.
     */
    dangerouslyGetChildren(rootID: string = DEFAULT_ROOT_ID) {
      const container = rootContainers.get(rootID);
      return getChildren(container);
    },

    /**
     * Prefer using `getPendingChildrenAsJSX`.
     * Using the returned children in `.toEqual` has very poor performance on mismatch due to deep equality checking of fiber structures.
     * Make sure you deeply remove enumerable properties before passing it to `.toEqual`, or, better, use `getChildrenAsJSX` or `toMatchRenderedOutput`.
     */
    dangerouslyGetPendingChildren(rootID: string = DEFAULT_ROOT_ID) {
      const container = rootContainers.get(rootID);
      return getPendingChildren(container);
    },

    getOrCreateRootContainer(rootID: string = DEFAULT_ROOT_ID, tag: RootTag) {
      let root = roots.get(rootID);
      if (!root) {
        const container = {rootID: rootID, pendingChildren: [], children: []};
        rootContainers.set(rootID, container);
        root = NoopRenderer.createContainer(
          container,
          tag,
          null,
          null,
          false,
          '',
          onRecoverableError,
          null,
        );
        roots.set(rootID, root);
      }
      return root.current.stateNode.containerInfo;
    },

    // TODO: Replace ReactNoop.render with createRoot + root.render
    createRoot(options?: CreateRootOptions) {
      const container = {
        rootID: '' + idCounter++,
        pendingChildren: [],
        children: [],
      };
      const fiberRoot = NoopRenderer.createContainer(
        container,
        ConcurrentRoot,
        null,
        null,
        false,
        '',
        onRecoverableError,
        options && options.unstable_transitionCallbacks
          ? options.unstable_transitionCallbacks
          : null,
      );
      return {
        _Scheduler: Scheduler,
        render(children: ReactNodeList) {
          NoopRenderer.updateContainer(children, fiberRoot, null, null);
        },
        getChildren() {
          return getChildren(container);
        },
        getChildrenAsJSX() {
          return getChildrenAsJSX(container);
        },
      };
    },

    createLegacyRoot() {
      const container = {
        rootID: '' + idCounter++,
        pendingChildren: [],
        children: [],
      };
      const fiberRoot = NoopRenderer.createContainer(
        container,
        LegacyRoot,
        null,
        null,
        false,
        '',
        onRecoverableError,
        null,
      );
      return {
        _Scheduler: Scheduler,
        render(children: ReactNodeList) {
          NoopRenderer.updateContainer(children, fiberRoot, null, null);
        },
        getChildren() {
          return getChildren(container);
        },
        getChildrenAsJSX() {
          return getChildrenAsJSX(container);
        },
      };
    },

    getChildrenAsJSX(rootID: string = DEFAULT_ROOT_ID) {
      const container = rootContainers.get(rootID);
      return getChildrenAsJSX(container);
    },

    getPendingChildrenAsJSX(rootID: string = DEFAULT_ROOT_ID) {
      const container = rootContainers.get(rootID);
      return getPendingChildrenAsJSX(container);
    },

    getSuspenseyThingStatus(src): string | null {
      if (suspenseyThingCache === null) {
        return null;
      } else {
        const record = suspenseyThingCache.get(src);
        return record === undefined ? null : record.status;
      }
    },

    resolveSuspenseyThing(key: string): void {
      if (suspenseyThingCache === null) {
        suspenseyThingCache = new Map();
      }
      const record = suspenseyThingCache.get(key);
      if (record === undefined) {
        const newRecord: SuspenseyThingRecord = {
          status: 'fulfilled',
          subscriptions: null,
        };
        suspenseyThingCache.set(key, newRecord);
      } else {
        if (record.status === 'pending') {
          record.status = 'fulfilled';
          const subscriptions = record.subscriptions;
          if (subscriptions !== null) {
            record.subscriptions = null;
            for (let i = 0; i < subscriptions.length; i++) {
              const subscription = subscriptions[i];
              subscription.pendingCount--;
              if (subscription.pendingCount === 0) {
                const commit = subscription.commit;
                subscription.commit = null;
                commit();
              }
            }
          }
        }
      }
    },

    resetSuspenseyThingCache() {
      suspenseyThingCache = null;
    },

    createPortal(
      children: ReactNodeList,
      container: Container,
      key: ?string = null,
    ) {
      return NoopRenderer.createPortal(children, container, null, key);
    },

    // Shortcut for testing a single root
    render(element: React$Element<any>, callback: ?Function) {
      ReactNoop.renderToRootWithID(element, DEFAULT_ROOT_ID, callback);
    },

    renderLegacySyncRoot(element: React$Element<any>, callback: ?Function) {
      const rootID = DEFAULT_ROOT_ID;
      const container = ReactNoop.getOrCreateRootContainer(rootID, LegacyRoot);
      const root = roots.get(container.rootID);
      NoopRenderer.updateContainer(element, root, null, callback);
    },

    renderToRootWithID(
      element: React$Element<any>,
      rootID: string,
      callback: ?Function,
    ) {
      const container = ReactNoop.getOrCreateRootContainer(
        rootID,
        ConcurrentRoot,
      );
      const root = roots.get(container.rootID);
      NoopRenderer.updateContainer(element, root, null, callback);
    },

    unmountRootWithID(rootID: string) {
      const root = roots.get(rootID);
      if (root) {
        NoopRenderer.updateContainer(null, root, null, () => {
          roots.delete(rootID);
          rootContainers.delete(rootID);
        });
      }
    },

    findInstance(
      componentOrElement: Element | ?React$Component<any, any>,
    ): null | Instance | TextInstance {
      if (componentOrElement == null) {
        return null;
      }
      // Unsound duck typing.
      const component = (componentOrElement: any);
      if (typeof component.id === 'number') {
        return component;
      }
      if (__DEV__) {
        return NoopRenderer.findHostInstanceWithWarning(
          component,
          'findInstance',
        );
      }
      return NoopRenderer.findHostInstance(component);
    },

    flushNextYield(): Array<mixed> {
      Scheduler.unstable_flushNumberOfYields(1);
      return Scheduler.unstable_clearLog();
    },

    startTrackingHostCounters(): void {
      hostUpdateCounter = 0;
      hostCloneCounter = 0;
    },

    stopTrackingHostCounters():
      | {
          hostUpdateCounter: number,
        }
      | {
          hostCloneCounter: number,
        } {
      const result = useMutation
        ? {
            hostUpdateCounter,
          }
        : {
            hostCloneCounter,
          };
      hostUpdateCounter = 0;
      hostCloneCounter = 0;

      return result;
    },

    expire: Scheduler.unstable_advanceTime,

    flushExpired(): Array<mixed> {
      return Scheduler.unstable_flushExpired();
    },

    unstable_runWithPriority: NoopRenderer.runWithPriority,

    batchedUpdates: NoopRenderer.batchedUpdates,

    deferredUpdates: NoopRenderer.deferredUpdates,

    discreteUpdates: NoopRenderer.discreteUpdates,

    idleUpdates<T>(fn: () => T): T {
      const prevEventPriority = currentEventPriority;
      currentEventPriority = IdleEventPriority;
      try {
        fn();
      } finally {
        currentEventPriority = prevEventPriority;
      }
    },

    flushSync,
    flushPassiveEffects: NoopRenderer.flushPassiveEffects,

    // Logs the current state of the tree.
    dumpTree(rootID: string = DEFAULT_ROOT_ID) {
      const root = roots.get(rootID);
      const rootContainer = rootContainers.get(rootID);
      if (!root || !rootContainer) {
        // eslint-disable-next-line react-internal/no-production-logging
        console.log('Nothing rendered yet.');
        return;
      }

      const bufferedLog = [];
      function log(...args) {
        bufferedLog.push(...args, '\n');
      }

      function logHostInstances(
        children: Array<Instance | TextInstance>,
        depth,
      ) {
        for (let i = 0; i < children.length; i++) {
          const child = children[i];
          const indent = '  '.repeat(depth);
          if (typeof child.text === 'string') {
            log(indent + '- ' + child.text);
          } else {
            log(indent + '- ' + child.type + '#' + child.id);
            logHostInstances(child.children, depth + 1);
          }
        }
      }
      function logContainer(container: Container, depth) {
        log('  '.repeat(depth) + '- [root#' + container.rootID + ']');
        logHostInstances(container.children, depth + 1);
      }

      function logUpdateQueue(updateQueue: UpdateQueue<mixed>, depth) {
        log('  '.repeat(depth + 1) + 'QUEUED UPDATES');
        const first = updateQueue.firstBaseUpdate;
        const update = first;
        if (update !== null) {
          do {
            log(
              '  '.repeat(depth + 1) + '~',
              '[' + update.expirationTime + ']',
            );
          } while (update !== null);
        }

        const lastPending = updateQueue.shared.pending;
        if (lastPending !== null) {
          const firstPending = lastPending.next;
          const pendingUpdate = firstPending;
          if (pendingUpdate !== null) {
            do {
              log(
                '  '.repeat(depth + 1) + '~',
                '[' + pendingUpdate.expirationTime + ']',
              );
            } while (pendingUpdate !== null && pendingUpdate !== firstPending);
          }
        }
      }

      function logFiber(fiber: Fiber, depth) {
        log(
          '  '.repeat(depth) +
            '- ' +
            // need to explicitly coerce Symbol to a string
            (fiber.type ? fiber.type.name || fiber.type.toString() : '[root]'),
          '[' +
            fiber.childExpirationTime +
            (fiber.pendingProps ? '*' : '') +
            ']',
        );
        if (fiber.updateQueue) {
          logUpdateQueue(fiber.updateQueue, depth);
        }
        // const childInProgress = fiber.progressedChild;
        // if (childInProgress && childInProgress !== fiber.child) {
        //   log(
        //     '  '.repeat(depth + 1) + 'IN PROGRESS: ' + fiber.pendingWorkPriority,
        //   );
        //   logFiber(childInProgress, depth + 1);
        //   if (fiber.child) {
        //     log('  '.repeat(depth + 1) + 'CURRENT');
        //   }
        // } else if (fiber.child && fiber.updateQueue) {
        //   log('  '.repeat(depth + 1) + 'CHILDREN');
        // }
        if (fiber.child) {
          logFiber(fiber.child, depth + 1);
        }
        if (fiber.sibling) {
          logFiber(fiber.sibling, depth);
        }
      }

      log('HOST INSTANCES:');
      logContainer(rootContainer, 0);
      log('FIBERS:');
      logFiber(root.current, 0);

      // eslint-disable-next-line react-internal/no-production-logging
      console.log(...bufferedLog);
    },

    getRoot(rootID: string = DEFAULT_ROOT_ID) {
      return roots.get(rootID);
    },
  };

  return ReactNoop;
}

export default createReactNoop;
