diff --git a/packages/react-dom/src/__tests__/ReactDOMRoot-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMRoot-test.internal.js
index 1529cf313c4c2..fd3fd0a07bb79 100644
--- a/packages/react-dom/src/__tests__/ReactDOMRoot-test.internal.js
+++ b/packages/react-dom/src/__tests__/ReactDOMRoot-test.internal.js
@@ -9,16 +9,68 @@
'use strict';
-require('shared/ReactFeatureFlags').enableCreateRoot = true;
var React = require('react');
var ReactDOM = require('react-dom');
var ReactDOMServer = require('react-dom/server');
+var AsyncComponent = React.unstable_AsyncComponent;
describe('ReactDOMRoot', () => {
let container;
+ let scheduledCallback;
+ let flush;
+ let now;
+ let expire;
+
beforeEach(() => {
container = document.createElement('div');
+
+ // Override requestIdleCallback
+ scheduledCallback = null;
+ flush = function(units = Infinity) {
+ if (scheduledCallback !== null) {
+ var didStop = false;
+ while (scheduledCallback !== null && !didStop) {
+ const cb = scheduledCallback;
+ scheduledCallback = null;
+ cb({
+ timeRemaining() {
+ if (units > 0) {
+ return 999;
+ }
+ didStop = true;
+ return 0;
+ },
+ });
+ units--;
+ }
+ }
+ };
+ global.performance = {
+ now() {
+ return now;
+ },
+ };
+ global.requestIdleCallback = function(cb) {
+ scheduledCallback = cb;
+ };
+
+ now = 0;
+ expire = function(ms) {
+ now += ms;
+ };
+ global.performance = {
+ now() {
+ return now;
+ },
+ };
+
+ jest.resetModules();
+ require('shared/ReactFeatureFlags').enableCreateRoot = true;
+ React = require('react');
+ ReactDOM = require('react-dom');
+ ReactDOMServer = require('react-dom/server');
+ AsyncComponent = React.unstable_AsyncComponent;
});
it('renders children', () => {
@@ -35,6 +87,35 @@ describe('ReactDOMRoot', () => {
expect(container.textContent).toEqual('');
});
+ it('`root.render` returns a thenable work object', () => {
+ const root = ReactDOM.createRoot(container);
+ const work = root.render(Hi);
+ let ops = [];
+ work.then(() => {
+ ops.push('inside callback: ' + container.textContent);
+ });
+ ops.push('before committing: ' + container.textContent);
+ flush();
+ ops.push('after committing: ' + container.textContent);
+ expect(ops).toEqual([
+ 'before committing: ',
+ // `then` callback should fire during commit phase
+ 'inside callback: Hi',
+ 'after committing: Hi',
+ ]);
+ });
+
+ it('resolves `work.then` callback synchronously if the work already committed', () => {
+ const root = ReactDOM.createRoot(container);
+ const work = root.render(Hi);
+ flush();
+ let ops = [];
+ work.then(() => {
+ ops.push('inside callback');
+ });
+ expect(ops).toEqual(['inside callback']);
+ });
+
it('supports hydration', async () => {
const markup = await new Promise(resolve =>
resolve(
@@ -95,4 +176,152 @@ describe('ReactDOMRoot', () => {
);
expect(container.textContent).toEqual('abdc');
});
+
+ it('can defer a commit by batching it', () => {
+ const root = ReactDOM.createRoot(container);
+ const batch = root.createBatch();
+ batch.render(
Hi
);
+ // Hasn't committed yet
+ expect(container.textContent).toEqual('');
+ // Commit
+ batch.commit();
+ expect(container.textContent).toEqual('Hi');
+ });
+
+ it('does not restart a completed batch when committing if there were no intervening updates', () => {
+ let ops = [];
+ function Foo(props) {
+ ops.push('Foo');
+ return props.children;
+ }
+ const root = ReactDOM.createRoot(container);
+ const batch = root.createBatch();
+ batch.render(Hi);
+ // Flush all async work.
+ flush();
+ // Root should complete without committing.
+ expect(ops).toEqual(['Foo']);
+ expect(container.textContent).toEqual('');
+
+ ops = [];
+
+ // Commit. Shouldn't re-render Foo.
+ batch.commit();
+ expect(ops).toEqual([]);
+ expect(container.textContent).toEqual('Hi');
+ });
+
+ it('can wait for a batch to finish', () => {
+ const Async = React.unstable_AsyncComponent;
+ const root = ReactDOM.createRoot(container);
+ const batch = root.createBatch();
+ batch.render(Foo);
+
+ flush();
+
+ // Hasn't updated yet
+ expect(container.textContent).toEqual('');
+
+ let ops = [];
+ batch.then(() => {
+ // Still hasn't updated
+ ops.push(container.textContent);
+ // Should synchronously commit
+ batch.commit();
+ ops.push(container.textContent);
+ });
+
+ expect(ops).toEqual(['', 'Foo']);
+ });
+
+ it('`batch.render` returns a thenable work object', () => {
+ const root = ReactDOM.createRoot(container);
+ const batch = root.createBatch();
+ const work = batch.render('Hi');
+ let ops = [];
+ work.then(() => {
+ ops.push('inside callback: ' + container.textContent);
+ });
+ ops.push('before committing: ' + container.textContent);
+ batch.commit();
+ ops.push('after committing: ' + container.textContent);
+ expect(ops).toEqual([
+ 'before committing: ',
+ // `then` callback should fire during commit phase
+ 'inside callback: Hi',
+ 'after committing: Hi',
+ ]);
+ });
+
+ it('can commit an empty batch', () => {
+ const root = ReactDOM.createRoot(container);
+ root.render(1);
+
+ expire(2000);
+ // This batch has a later expiration time than the earlier update.
+ const batch = root.createBatch();
+
+ // This should not flush the earlier update.
+ batch.commit();
+ expect(container.textContent).toEqual('');
+
+ flush();
+ expect(container.textContent).toEqual('1');
+ });
+
+ it('two batches created simultaneously are committed separately', () => {
+ // (In other words, they have distinct expiration times)
+ const root = ReactDOM.createRoot(container);
+ const batch1 = root.createBatch();
+ batch1.render(1);
+ const batch2 = root.createBatch();
+ batch2.render(2);
+
+ expect(container.textContent).toEqual('');
+
+ batch1.commit();
+ expect(container.textContent).toEqual('1');
+
+ batch2.commit();
+ expect(container.textContent).toEqual('2');
+ });
+
+ it('commits an earlier batch without committing a later batch', () => {
+ const root = ReactDOM.createRoot(container);
+ const batch1 = root.createBatch();
+ batch1.render(1);
+
+ // This batch has a later expiration time
+ expire(2000);
+ const batch2 = root.createBatch();
+ batch2.render(2);
+
+ expect(container.textContent).toEqual('');
+
+ batch1.commit();
+ expect(container.textContent).toEqual('1');
+
+ batch2.commit();
+ expect(container.textContent).toEqual('2');
+ });
+
+ it('commits a later batch without commiting an earlier batch', () => {
+ const root = ReactDOM.createRoot(container);
+ const batch1 = root.createBatch();
+ batch1.render(1);
+
+ // This batch has a later expiration time
+ expire(2000);
+ const batch2 = root.createBatch();
+ batch2.render(2);
+
+ expect(container.textContent).toEqual('');
+
+ batch2.commit();
+ expect(container.textContent).toEqual('2');
+
+ batch1.commit();
+ flush();
+ expect(container.textContent).toEqual('1');
+ });
});
diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js
index 74744384aca3e..76de75a36e45d 100644
--- a/packages/react-dom/src/client/ReactDOM.js
+++ b/packages/react-dom/src/client/ReactDOM.js
@@ -8,6 +8,12 @@
*/
import type {ReactNodeList} from 'shared/ReactTypes';
+// TODO: This type is shared between the reconciler and ReactDOM, but will
+// eventually be lifted out to the renderer.
+import type {
+ FiberRoot,
+ Batch as FiberRootBatch,
+} from 'react-reconciler/src/ReactFiberRoot';
import '../shared/checkReact';
import '../shared/ReactDOMInjection';
@@ -84,6 +90,63 @@ if (__DEV__) {
'polyfill in older browsers. http://fb.me/react-polyfills',
);
}
+
+ var topLevelUpdateWarnings = (container: DOMContainer) => {
+ if (__DEV__) {
+ if (
+ container._reactRootContainer &&
+ container.nodeType !== COMMENT_NODE
+ ) {
+ const hostInstance = DOMRenderer.findHostInstanceWithNoPortals(
+ container._reactRootContainer._internalRoot.current,
+ );
+ if (hostInstance) {
+ warning(
+ hostInstance.parentNode === container,
+ 'render(...): It looks like the React-rendered content of this ' +
+ 'container was removed without using React. This is not ' +
+ 'supported and will cause errors. Instead, call ' +
+ 'ReactDOM.unmountComponentAtNode to empty a container.',
+ );
+ }
+ }
+
+ const isRootRenderedBySomeReact = !!container._reactRootContainer;
+ const rootEl = getReactRootElementInContainer(container);
+ const hasNonRootReactChild = !!(
+ rootEl && ReactDOMComponentTree.getInstanceFromNode(rootEl)
+ );
+
+ warning(
+ !hasNonRootReactChild || isRootRenderedBySomeReact,
+ 'render(...): Replacing React-rendered children with a new root ' +
+ 'component. If you intended to update the children of this node, ' +
+ 'you should instead have the existing children update their state ' +
+ 'and render the new components instead of calling ReactDOM.render.',
+ );
+
+ warning(
+ container.nodeType !== ELEMENT_NODE ||
+ !((container: any): Element).tagName ||
+ ((container: any): Element).tagName.toUpperCase() !== 'BODY',
+ 'render(): Rendering components directly into document.body is ' +
+ 'discouraged, since its children are often manipulated by third-party ' +
+ 'scripts and browser extensions. This may lead to subtle ' +
+ 'reconciliation issues. Try rendering into a container element created ' +
+ 'for your app.',
+ );
+ }
+ };
+
+ var warnOnInvalidCallback = function(callback: mixed, callerName: string) {
+ warning(
+ callback === null || typeof callback === 'function',
+ '%s(...): Expected the last optional `callback` argument to be a ' +
+ 'function. Instead received: %s.',
+ callerName,
+ callback,
+ );
+ };
}
ReactControlledComponent.injection.injectFiberControlledHostComponent(
@@ -92,10 +155,10 @@ ReactControlledComponent.injection.injectFiberControlledHostComponent(
type DOMContainer =
| (Element & {
- _reactRootContainer: ?Object,
+ _reactRootContainer: ?Root,
})
| (Document & {
- _reactRootContainer: ?Object,
+ _reactRootContainer: ?Root,
});
type Container = Element | Document;
@@ -118,6 +181,281 @@ type HostContext = HostContextDev | HostContextProd;
let eventsEnabled: ?boolean = null;
let selectionInformation: ?mixed = null;
+type Batch = FiberRootBatch & {
+ render(children: ReactNodeList): Work,
+ then(onComplete: () => mixed): void,
+ commit(): void,
+
+ // The ReactRoot constuctor is hoisted but the prototype methods are not. If
+ // we move ReactRoot to be above ReactBatch, the inverse error occurs.
+ // $FlowFixMe Hoisting issue.
+ _root: Root,
+ _hasChildren: boolean,
+ _children: ReactNodeList,
+
+ _callbacks: Array<() => mixed> | null,
+ _didComplete: boolean,
+};
+
+function ReactBatch(root: ReactRoot) {
+ const expirationTime = DOMRenderer.computeUniqueAsyncExpiration();
+ this._expirationTime = expirationTime;
+ this._root = root;
+ this._next = null;
+ this._callbacks = null;
+ this._didComplete = false;
+ this._hasChildren = false;
+ this._children = null;
+ this._defer = true;
+}
+ReactBatch.prototype.render = function(children: ReactNodeList) {
+ invariant(
+ this._defer,
+ 'batch.render: Cannot render a batch that already committed.',
+ );
+ this._hasChildren = true;
+ this._children = children;
+ const internalRoot = this._root._internalRoot;
+ const expirationTime = this._expirationTime;
+ const work = new ReactWork();
+ DOMRenderer.updateContainerAtExpirationTime(
+ children,
+ internalRoot,
+ null,
+ expirationTime,
+ work._onCommit,
+ );
+ return work;
+};
+ReactBatch.prototype.then = function(onComplete: () => mixed) {
+ if (this._didComplete) {
+ onComplete();
+ return;
+ }
+ let callbacks = this._callbacks;
+ if (callbacks === null) {
+ callbacks = this._callbacks = [];
+ }
+ callbacks.push(onComplete);
+};
+ReactBatch.prototype.commit = function() {
+ const internalRoot = this._root._internalRoot;
+ let firstBatch = internalRoot.firstBatch;
+ invariant(
+ this._defer && firstBatch !== null,
+ 'batch.commit: Cannot commit a batch multiple times.',
+ );
+
+ if (!this._hasChildren) {
+ // This batch is empty. Return.
+ this._next = null;
+ this._defer = false;
+ return;
+ }
+
+ let expirationTime = this._expirationTime;
+
+ // Ensure this is the first batch in the list.
+ if (firstBatch !== this) {
+ // This batch is not the earliest batch. We need to move it to the front.
+ // Update its expiration time to be the expiration time of the earliest
+ // batch, so that we can flush it without flushing the other batches.
+ if (this._hasChildren) {
+ expirationTime = this._expirationTime = firstBatch._expirationTime;
+ // Rendering this batch again ensures its children will be the final state
+ // when we flush (updates are processed in insertion order: last
+ // update wins).
+ // TODO: This forces a restart. Should we print a warning?
+ this.render(this._children);
+ }
+
+ // Remove the batch from the list.
+ let previous = null;
+ let batch = firstBatch;
+ while (batch !== this) {
+ previous = batch;
+ batch = batch._next;
+ }
+ invariant(
+ previous !== null,
+ 'batch.commit: Cannot commit a batch multiple times.',
+ );
+ previous._next = batch._next;
+
+ // Add it to the front.
+ this._next = firstBatch;
+ firstBatch = internalRoot.firstBatch = this;
+ }
+
+ // Synchronously flush all the work up to this batch's expiration time.
+ this._defer = false;
+ DOMRenderer.flushRoot(internalRoot, expirationTime);
+
+ // Pop the batch from the list.
+ const next = this._next;
+ this._next = null;
+ firstBatch = internalRoot.firstBatch = next;
+
+ // Append the next earliest batch's children to the update queue.
+ if (firstBatch !== null && firstBatch._hasChildren) {
+ firstBatch.render(firstBatch._children);
+ }
+};
+ReactBatch.prototype._onComplete = function() {
+ if (this._didComplete) {
+ return;
+ }
+ this._didComplete = true;
+ const callbacks = this._callbacks;
+ if (callbacks === null) {
+ return;
+ }
+ // TODO: Error handling.
+ for (let i = 0; i < callbacks.length; i++) {
+ const callback = callbacks[i];
+ callback();
+ }
+};
+
+type Work = {
+ then(onCommit: () => mixed): void,
+ _onCommit: () => void,
+ _callbacks: Array<() => mixed> | null,
+ _didCommit: boolean,
+};
+
+function ReactWork() {
+ this._callbacks = null;
+ this._didCommit = false;
+ // TODO: Avoid need to bind by replacing callbacks in the update queue with
+ // list of Work objects.
+ this._onCommit = this._onCommit.bind(this);
+}
+ReactWork.prototype.then = function(onCommit: () => mixed): void {
+ if (this._didCommit) {
+ onCommit();
+ return;
+ }
+ let callbacks = this._callbacks;
+ if (callbacks === null) {
+ callbacks = this._callbacks = [];
+ }
+ callbacks.push(onCommit);
+};
+ReactWork.prototype._onCommit = function(): void {
+ if (this._didCommit) {
+ return;
+ }
+ this._didCommit = true;
+ const callbacks = this._callbacks;
+ if (callbacks === null) {
+ return;
+ }
+ // TODO: Error handling.
+ for (let i = 0; i < callbacks.length; i++) {
+ const callback = callbacks[i];
+ invariant(
+ typeof callback === 'function',
+ 'Invalid argument passed as callback. Expected a function. Instead ' +
+ 'received: %s',
+ callback,
+ );
+ callback();
+ }
+};
+
+type Root = {
+ render(children: ReactNodeList, callback: ?() => mixed): Work,
+ unmount(callback: ?() => mixed): Work,
+ legacy_renderSubtreeIntoContainer(
+ parentComponent: ?React$Component,
+ children: ReactNodeList,
+ callback: ?() => mixed,
+ ): Work,
+ createBatch(): Batch,
+
+ _internalRoot: FiberRoot,
+};
+
+function ReactRoot(container: Container, hydrate: boolean) {
+ const root = DOMRenderer.createContainer(container, hydrate);
+ this._internalRoot = root;
+}
+ReactRoot.prototype.render = function(
+ children: ReactNodeList,
+ callback: ?() => mixed,
+): Work {
+ const root = this._internalRoot;
+ const work = new ReactWork();
+ callback = callback === undefined ? null : callback;
+ if (__DEV__) {
+ warnOnInvalidCallback(callback, 'render');
+ }
+ if (callback !== null) {
+ work.then(callback);
+ }
+ DOMRenderer.updateContainer(children, root, null, work._onCommit);
+ return work;
+};
+ReactRoot.prototype.unmount = function(callback: ?() => mixed): Work {
+ const root = this._internalRoot;
+ const work = new ReactWork();
+ callback = callback === undefined ? null : callback;
+ if (__DEV__) {
+ warnOnInvalidCallback(callback, 'render');
+ }
+ if (callback !== null) {
+ work.then(callback);
+ }
+ DOMRenderer.updateContainer(null, root, null, work._onCommit);
+ return work;
+};
+ReactRoot.prototype.legacy_renderSubtreeIntoContainer = function(
+ parentComponent: ?React$Component,
+ children: ReactNodeList,
+ callback: ?() => mixed,
+): Work {
+ const root = this._internalRoot;
+ const work = new ReactWork();
+ callback = callback === undefined ? null : callback;
+ if (__DEV__) {
+ warnOnInvalidCallback(callback, 'render');
+ }
+ if (callback !== null) {
+ work.then(callback);
+ }
+ DOMRenderer.updateContainer(children, root, parentComponent, work._onCommit);
+ return work;
+};
+ReactRoot.prototype.createBatch = function(): Batch {
+ const batch = new ReactBatch(this);
+ const expirationTime = batch._expirationTime;
+
+ const internalRoot = this._internalRoot;
+ const firstBatch = internalRoot.firstBatch;
+ if (firstBatch === null) {
+ internalRoot.firstBatch = batch;
+ batch._next = null;
+ } else {
+ // Insert sorted by expiration time then insertion order
+ let insertAfter = null;
+ let insertBefore = firstBatch;
+ while (
+ insertBefore !== null &&
+ insertBefore._expirationTime <= expirationTime
+ ) {
+ insertAfter = insertBefore;
+ insertBefore = insertBefore._next;
+ }
+ batch._next = insertBefore;
+ if (insertAfter !== null) {
+ insertAfter._next = batch;
+ }
+ }
+
+ return batch;
+};
+
/**
* True if the supplied DOM node is a valid node element.
*
@@ -654,108 +992,115 @@ ReactGenericBatching.injection.injectFiberBatchedUpdates(
let warnedAboutHydrateAPI = false;
-function renderSubtreeIntoContainer(
+function legacyCreateRootFromDOMContainer(
+ container: DOMContainer,
+ forceHydrate: boolean,
+): Root {
+ const shouldHydrate =
+ forceHydrate || shouldHydrateDueToLegacyHeuristic(container);
+ // First clear any existing content.
+ if (!shouldHydrate) {
+ let warned = false;
+ let rootSibling;
+ while ((rootSibling = container.lastChild)) {
+ if (__DEV__) {
+ if (
+ !warned &&
+ rootSibling.nodeType === ELEMENT_NODE &&
+ (rootSibling: any).hasAttribute(ROOT_ATTRIBUTE_NAME)
+ ) {
+ warned = true;
+ warning(
+ false,
+ 'render(): Target node has markup rendered by React, but there ' +
+ 'are unrelated nodes as well. This is most commonly caused by ' +
+ 'white-space inserted around server-rendered markup.',
+ );
+ }
+ }
+ container.removeChild(rootSibling);
+ }
+ }
+ if (__DEV__) {
+ if (shouldHydrate && !forceHydrate && !warnedAboutHydrateAPI) {
+ warnedAboutHydrateAPI = true;
+ lowPriorityWarning(
+ false,
+ 'render(): Calling ReactDOM.render() to hydrate server-rendered markup ' +
+ 'will stop working in React v17. Replace the ReactDOM.render() call ' +
+ 'with ReactDOM.hydrate() if you want React to attach to the server HTML.',
+ );
+ }
+ }
+ const root: Root = new ReactRoot(container, shouldHydrate);
+ return root;
+}
+
+function legacyRenderSubtreeIntoContainer(
parentComponent: ?React$Component,
children: ReactNodeList,
container: DOMContainer,
forceHydrate: boolean,
callback: ?Function,
) {
+ // TODO: Ensure all entry points contain this check
invariant(
isValidContainer(container),
'Target container is not a DOM element.',
);
if (__DEV__) {
- if (container._reactRootContainer && container.nodeType !== COMMENT_NODE) {
- const hostInstance = DOMRenderer.findHostInstanceWithNoPortals(
- container._reactRootContainer.current,
- );
- if (hostInstance) {
- warning(
- hostInstance.parentNode === container,
- 'render(...): It looks like the React-rendered content of this ' +
- 'container was removed without using React. This is not ' +
- 'supported and will cause errors. Instead, call ' +
- 'ReactDOM.unmountComponentAtNode to empty a container.',
- );
- }
- }
-
- const isRootRenderedBySomeReact = !!container._reactRootContainer;
- const rootEl = getReactRootElementInContainer(container);
- const hasNonRootReactChild = !!(
- rootEl && ReactDOMComponentTree.getInstanceFromNode(rootEl)
- );
-
- warning(
- !hasNonRootReactChild || isRootRenderedBySomeReact,
- 'render(...): Replacing React-rendered children with a new root ' +
- 'component. If you intended to update the children of this node, ' +
- 'you should instead have the existing children update their state ' +
- 'and render the new components instead of calling ReactDOM.render.',
- );
-
- warning(
- container.nodeType !== ELEMENT_NODE ||
- !((container: any): Element).tagName ||
- ((container: any): Element).tagName.toUpperCase() !== 'BODY',
- 'render(): Rendering components directly into document.body is ' +
- 'discouraged, since its children are often manipulated by third-party ' +
- 'scripts and browser extensions. This may lead to subtle ' +
- 'reconciliation issues. Try rendering into a container element created ' +
- 'for your app.',
- );
+ topLevelUpdateWarnings(container);
}
- let root = container._reactRootContainer;
+ // TODO: Without `any` type, Flow says "Property cannot be accessed on any
+ // member of intersection type." Whyyyyyy.
+ let root: Root = (container._reactRootContainer: any);
if (!root) {
- const shouldHydrate =
- forceHydrate || shouldHydrateDueToLegacyHeuristic(container);
- // First clear any existing content.
- if (!shouldHydrate) {
- let warned = false;
- let rootSibling;
- while ((rootSibling = container.lastChild)) {
- if (__DEV__) {
- if (
- !warned &&
- rootSibling.nodeType === ELEMENT_NODE &&
- (rootSibling: any).hasAttribute(ROOT_ATTRIBUTE_NAME)
- ) {
- warned = true;
- warning(
- false,
- 'render(): Target node has markup rendered by React, but there ' +
- 'are unrelated nodes as well. This is most commonly caused by ' +
- 'white-space inserted around server-rendered markup.',
- );
- }
- }
- container.removeChild(rootSibling);
- }
- }
- if (__DEV__) {
- if (shouldHydrate && !forceHydrate && !warnedAboutHydrateAPI) {
- warnedAboutHydrateAPI = true;
- lowPriorityWarning(
- false,
- 'render(): Calling ReactDOM.render() to hydrate server-rendered markup ' +
- 'will stop working in React v17. Replace the ReactDOM.render() call ' +
- 'with ReactDOM.hydrate() if you want React to attach to the server HTML.',
- );
- }
+ // Initial mount
+ root = container._reactRootContainer = legacyCreateRootFromDOMContainer(
+ container,
+ forceHydrate,
+ );
+ if (typeof callback === 'function') {
+ const originalCallback = callback;
+ callback = function() {
+ const instance = DOMRenderer.getPublicRootInstance(root._internalRoot);
+ originalCallback.call(instance);
+ };
}
- const newRoot = DOMRenderer.createContainer(container, shouldHydrate);
- root = container._reactRootContainer = newRoot;
// Initial mount should not be batched.
DOMRenderer.unbatchedUpdates(() => {
- DOMRenderer.updateContainer(children, newRoot, parentComponent, callback);
+ if (parentComponent != null) {
+ root.legacy_renderSubtreeIntoContainer(
+ parentComponent,
+ children,
+ callback,
+ );
+ } else {
+ root.render(children, callback);
+ }
});
} else {
- DOMRenderer.updateContainer(children, root, parentComponent, callback);
+ if (typeof callback === 'function') {
+ const originalCallback = callback;
+ callback = function() {
+ const instance = DOMRenderer.getPublicRootInstance(root._internalRoot);
+ originalCallback.call(instance);
+ };
+ }
+ // Update
+ if (parentComponent != null) {
+ root.legacy_renderSubtreeIntoContainer(
+ parentComponent,
+ children,
+ callback,
+ );
+ } else {
+ root.render(children, callback);
+ }
}
- return DOMRenderer.getPublicRootInstance(root);
+ return DOMRenderer.getPublicRootInstance(root._internalRoot);
}
function createPortal(
@@ -771,33 +1116,6 @@ function createPortal(
return ReactPortal.createPortal(children, container, null, key);
}
-type ReactRootNode = {
- render(children: ReactNodeList, callback: ?() => mixed): void,
- unmount(callback: ?() => mixed): void,
-
- _reactRootContainer: *,
-};
-
-type RootOptions = {
- hydrate?: boolean,
-};
-
-function ReactRoot(container: Container, hydrate: boolean) {
- const root = DOMRenderer.createContainer(container, hydrate);
- this._reactRootContainer = root;
-}
-ReactRoot.prototype.render = function(
- children: ReactNodeList,
- callback: ?() => mixed,
-): void {
- const root = this._reactRootContainer;
- DOMRenderer.updateContainer(children, root, null, callback);
-};
-ReactRoot.prototype.unmount = function(callback) {
- const root = this._reactRootContainer;
- DOMRenderer.updateContainer(null, root, null, callback);
-};
-
const ReactDOM: Object = {
createPortal,
@@ -846,7 +1164,13 @@ const ReactDOM: Object = {
hydrate(element: React$Node, container: DOMContainer, callback: ?Function) {
// TODO: throw or warn if we couldn't hydrate?
- return renderSubtreeIntoContainer(null, element, container, true, callback);
+ return legacyRenderSubtreeIntoContainer(
+ null,
+ element,
+ container,
+ true,
+ callback,
+ );
},
render(
@@ -854,7 +1178,7 @@ const ReactDOM: Object = {
container: DOMContainer,
callback: ?Function,
) {
- return renderSubtreeIntoContainer(
+ return legacyRenderSubtreeIntoContainer(
null,
element,
container,
@@ -873,7 +1197,7 @@ const ReactDOM: Object = {
parentComponent != null && ReactInstanceMap.has(parentComponent),
'parentComponent must be a valid React Component',
);
- return renderSubtreeIntoContainer(
+ return legacyRenderSubtreeIntoContainer(
parentComponent,
element,
containerNode,
@@ -902,7 +1226,7 @@ const ReactDOM: Object = {
// Unmount should not be batched.
DOMRenderer.unbatchedUpdates(() => {
- renderSubtreeIntoContainer(null, null, container, false, () => {
+ legacyRenderSubtreeIntoContainer(null, null, container, false, () => {
container._reactRootContainer = null;
});
});
@@ -960,11 +1284,15 @@ const ReactDOM: Object = {
},
};
+type RootOptions = {
+ hydrate?: boolean,
+};
+
if (enableCreateRoot) {
ReactDOM.createRoot = function createRoot(
container: DOMContainer,
options?: RootOptions,
- ): ReactRootNode {
+ ): ReactRoot {
const hydrate = options != null && options.hydrate === true;
return new ReactRoot(container, hydrate);
};
diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js
index 67894f0f9578c..d34fdbc8a5ea7 100644
--- a/packages/react-reconciler/src/ReactFiberReconciler.js
+++ b/packages/react-reconciler/src/ReactFiberReconciler.js
@@ -10,6 +10,7 @@
import type {Fiber} from './ReactFiber';
import type {FiberRoot} from './ReactFiberRoot';
import type {ReactNodeList} from 'shared/ReactTypes';
+import type {ExpirationTime} from './ReactFiberExpirationTime';
import {enableAsyncSubtreeAPI} from 'shared/ReactFeatureFlags';
import {
@@ -238,12 +239,22 @@ export type Reconciler = {
container: OpaqueRoot,
parentComponent: ?React$Component,
callback: ?Function,
- ): void,
+ ): ExpirationTime,
+ updateContainerAtExpirationTime(
+ element: ReactNodeList,
+ container: OpaqueRoot,
+ parentComponent: ?React$Component,
+ expirationTime: ExpirationTime,
+ callback: ?Function,
+ ): ExpirationTime,
+ flushRoot(root: OpaqueRoot, expirationTime: ExpirationTime): void,
+ requestWork(root: OpaqueRoot, expirationTime: ExpirationTime): void,
batchedUpdates(fn: () => A): A,
unbatchedUpdates(fn: () => A): A,
flushSync(fn: () => A): A,
deferredUpdates(fn: () => A): A,
injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean,
+ computeUniqueAsyncExpiration(): ExpirationTime,
// Used to extract the return value from the initial render. Legacy API.
getPublicRootInstance(
@@ -278,17 +289,40 @@ export default function(
var {
computeAsyncExpiration,
+ computeUniqueAsyncExpiration,
computeExpirationForFiber,
scheduleWork,
+ requestWork,
+ flushRoot,
batchedUpdates,
unbatchedUpdates,
flushSync,
deferredUpdates,
} = ReactFiberScheduler(config);
- function scheduleTopLevelUpdate(
+ function computeRootExpirationTime(current, element) {
+ let expirationTime;
+ // Check if the top-level element is an async wrapper component. If so,
+ // treat updates to the root as async. This is a bit weird but lets us
+ // avoid a separate `renderAsync` API.
+ if (
+ enableAsyncSubtreeAPI &&
+ element != null &&
+ (element: any).type != null &&
+ (element: any).type.prototype != null &&
+ (element: any).type.prototype.unstable_isAsyncReactComponent === true
+ ) {
+ expirationTime = computeAsyncExpiration();
+ } else {
+ expirationTime = computeExpirationForFiber(current);
+ }
+ return expirationTime;
+ }
+
+ function scheduleRootUpdate(
current: Fiber,
element: ReactNodeList,
+ expirationTime: ExpirationTime,
callback: ?Function,
) {
if (__DEV__) {
@@ -319,33 +353,50 @@ export default function(
);
}
- let expirationTime;
- // Check if the top-level element is an async wrapper component. If so,
- // treat updates to the root as async. This is a bit weird but lets us
- // avoid a separate `renderAsync` API.
- if (
- enableAsyncSubtreeAPI &&
- element != null &&
- (element: any).type != null &&
- (element: any).type.prototype != null &&
- (element: any).type.prototype.unstable_isAsyncReactComponent === true
- ) {
- expirationTime = computeAsyncExpiration();
- } else {
- expirationTime = computeExpirationForFiber(current);
- }
-
const update = {
expirationTime,
partialState: {element},
callback,
isReplace: false,
isForced: false,
- nextCallback: null,
next: null,
};
insertUpdateIntoFiber(current, update);
scheduleWork(current, expirationTime);
+
+ return expirationTime;
+ }
+
+ function updateContainerAtExpirationTime(
+ element: ReactNodeList,
+ container: OpaqueRoot,
+ parentComponent: ?React$Component,
+ expirationTime: ExpirationTime,
+ callback: ?Function,
+ ) {
+ // TODO: If this is a nested container, this won't be the root.
+ const current = container.current;
+
+ if (__DEV__) {
+ if (ReactFiberInstrumentation.debugTool) {
+ if (current.alternate === null) {
+ ReactFiberInstrumentation.debugTool.onMountContainer(container);
+ } else if (element === null) {
+ ReactFiberInstrumentation.debugTool.onUnmountContainer(container);
+ } else {
+ ReactFiberInstrumentation.debugTool.onUpdateContainer(container);
+ }
+ }
+ }
+
+ const context = getContextForSubtree(parentComponent);
+ if (container.context === null) {
+ container.context = context;
+ } else {
+ container.pendingContext = context;
+ }
+
+ return scheduleRootUpdate(current, element, expirationTime, callback);
}
function findHostInstance(fiber: Fiber): PI | null {
@@ -366,31 +417,25 @@ export default function(
container: OpaqueRoot,
parentComponent: ?React$Component,
callback: ?Function,
- ): void {
- // TODO: If this is a nested container, this won't be the root.
+ ): ExpirationTime {
const current = container.current;
+ const expirationTime = computeRootExpirationTime(current, element);
+ return updateContainerAtExpirationTime(
+ element,
+ container,
+ parentComponent,
+ expirationTime,
+ callback,
+ );
+ },
- if (__DEV__) {
- if (ReactFiberInstrumentation.debugTool) {
- if (current.alternate === null) {
- ReactFiberInstrumentation.debugTool.onMountContainer(container);
- } else if (element === null) {
- ReactFiberInstrumentation.debugTool.onUnmountContainer(container);
- } else {
- ReactFiberInstrumentation.debugTool.onUpdateContainer(container);
- }
- }
- }
+ updateContainerAtExpirationTime,
- const context = getContextForSubtree(parentComponent);
- if (container.context === null) {
- container.context = context;
- } else {
- container.pendingContext = context;
- }
+ flushRoot,
- scheduleTopLevelUpdate(current, element, callback);
- },
+ requestWork,
+
+ computeUniqueAsyncExpiration,
batchedUpdates,
diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js
index 4529d12d6f34f..b6e21ed6872e8 100644
--- a/packages/react-reconciler/src/ReactFiberRoot.js
+++ b/packages/react-reconciler/src/ReactFiberRoot.js
@@ -13,6 +13,14 @@ import type {ExpirationTime} from './ReactFiberExpirationTime';
import {createHostRootFiber} from './ReactFiber';
import {NoWork} from './ReactFiberExpirationTime';
+// TODO: This should be lifted into the renderer.
+export type Batch = {
+ _defer: boolean,
+ _expirationTime: ExpirationTime,
+ _onComplete: () => mixed,
+ _next: Batch | null,
+};
+
export type FiberRoot = {
// Any additional information from the host associated with this root.
containerInfo: any,
@@ -34,6 +42,10 @@ export type FiberRoot = {
pendingContext: Object | null,
// Determines if we should attempt to hydrate on the initial mount
+hydrate: boolean,
+ // List of top-level batches. This list indicates whether a commit should be
+ // deferred. Also contains completion callbacks.
+ // TODO: Lift this into the renderer
+ firstBatch: Batch | null,
// Linked-list of roots
nextScheduledRoot: FiberRoot | null,
};
@@ -55,6 +67,7 @@ export function createFiberRoot(
context: null,
pendingContext: null,
hydrate,
+ firstBatch: null,
nextScheduledRoot: null,
};
uninitializedFiber.stateNode = root;
diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js
index e18214eb13def..2284036e68ac2 100644
--- a/packages/react-reconciler/src/ReactFiberScheduler.js
+++ b/packages/react-reconciler/src/ReactFiberScheduler.js
@@ -9,7 +9,7 @@
import type {HostConfig, Deadline} from 'react-reconciler';
import type {Fiber} from './ReactFiber';
-import type {FiberRoot} from './ReactFiberRoot';
+import type {FiberRoot, Batch} from './ReactFiberRoot';
import type {HydrationContext} from './ReactFiberHydrationContext';
import type {ExpirationTime} from './ReactFiberExpirationTime';
@@ -187,6 +187,9 @@ export default function(
const startTime = now();
let mostRecentCurrentTime: ExpirationTime = msToExpirationTime(0);
+ // Used to ensure computeUniqueAsyncExpiration is monotonically increases.
+ let lastUniqueAsyncExpiration: number = 0;
+
// Represents the expiration time that incoming updates should use. (If this
// is NoWork, use the default strategy: async updates in async mode, sync
// updates in sync mode.)
@@ -1135,6 +1138,19 @@ export default function(
return computeExpirationBucket(currentTime, expirationMs, bucketSizeMs);
}
+ // Creates a unique async expiration time.
+ function computeUniqueAsyncExpiration(): ExpirationTime {
+ let result = computeAsyncExpiration();
+ if (result <= lastUniqueAsyncExpiration) {
+ // Since we assume the current time monotonically increases, we only hit
+ // this branch when computeUniqueAsyncExpiration is fired multiple times
+ // within a 200ms window (or whatever the async bucket size is).
+ result = lastUniqueAsyncExpiration + 1;
+ }
+ lastUniqueAsyncExpiration = result;
+ return lastUniqueAsyncExpiration;
+ }
+
function computeExpirationForFiber(fiber: Fiber) {
let expirationTime;
if (expirationContext !== NoWork) {
@@ -1292,6 +1308,8 @@ export default function(
let isBatchingUpdates: boolean = false;
let isUnbatchingUpdates: boolean = false;
+ let completedBatches: Array | null = null;
+
// Use these to prevent an infinite loop of nested updates
const NESTED_UPDATE_LIMIT = 1000;
let nestedUpdateCount: number = 0;
@@ -1374,7 +1392,7 @@ export default function(
// flush it now.
nextFlushedRoot = root;
nextFlushedExpirationTime = Sync;
- performWorkOnRoot(nextFlushedRoot, nextFlushedExpirationTime);
+ performWorkOnRoot(root, Sync, recalculateCurrentTime());
}
return;
}
@@ -1486,7 +1504,11 @@ export default function(
nextFlushedExpirationTime <= minExpirationTime) &&
!deadlineDidExpire
) {
- performWorkOnRoot(nextFlushedRoot, nextFlushedExpirationTime);
+ performWorkOnRoot(
+ nextFlushedRoot,
+ nextFlushedExpirationTime,
+ recalculateCurrentTime(),
+ );
// Find the next highest priority work.
findHighestPriorityRoot();
}
@@ -1509,6 +1531,39 @@ export default function(
deadlineDidExpire = false;
nestedUpdateCount = 0;
+ finishRendering();
+ }
+
+ function flushRoot(root: FiberRoot, expirationTime: ExpirationTime) {
+ invariant(
+ !isRendering,
+ 'work.commit(): Cannot commit while already rendering. This likely ' +
+ 'means you attempted to commit from inside a lifecycle method.',
+ );
+ // Perform work on root as if the given expiration time is the current time.
+ // This has the effect of synchronously flushing all work up to and
+ // including the given time.
+ performWorkOnRoot(root, expirationTime, expirationTime);
+ finishRendering();
+ }
+
+ function finishRendering() {
+ if (completedBatches !== null) {
+ const batches = completedBatches;
+ completedBatches = null;
+ for (let i = 0; i < batches.length; i++) {
+ const batch = batches[i];
+ try {
+ batch._onComplete();
+ } catch (error) {
+ if (!hasUnhandledError) {
+ hasUnhandledError = true;
+ unhandledError = error;
+ }
+ }
+ }
+ }
+
if (hasUnhandledError) {
const error = unhandledError;
unhandledError = null;
@@ -1517,7 +1572,11 @@ export default function(
}
}
- function performWorkOnRoot(root, expirationTime) {
+ function performWorkOnRoot(
+ root: FiberRoot,
+ expirationTime: ExpirationTime,
+ currentTime: ExpirationTime,
+ ) {
invariant(
!isRendering,
'performWorkOnRoot was called recursively. This error is likely caused ' +
@@ -1527,20 +1586,18 @@ export default function(
isRendering = true;
// Check if this is async work or sync/expired work.
- // TODO: Pass current time as argument to renderRoot, commitRoot
- if (expirationTime <= recalculateCurrentTime()) {
+ if (expirationTime <= currentTime) {
// Flush sync work.
let finishedWork = root.finishedWork;
if (finishedWork !== null) {
// This root is already complete. We can commit it.
- root.finishedWork = null;
- root.remainingExpirationTime = commitRoot(finishedWork);
+ completeRoot(root, finishedWork, expirationTime);
} else {
root.finishedWork = null;
finishedWork = renderRoot(root, expirationTime);
if (finishedWork !== null) {
// We've completed the root. Commit it.
- root.remainingExpirationTime = commitRoot(finishedWork);
+ completeRoot(root, finishedWork, expirationTime);
}
}
} else {
@@ -1548,8 +1605,7 @@ export default function(
let finishedWork = root.finishedWork;
if (finishedWork !== null) {
// This root is already complete. We can commit it.
- root.finishedWork = null;
- root.remainingExpirationTime = commitRoot(finishedWork);
+ completeRoot(root, finishedWork, expirationTime);
} else {
root.finishedWork = null;
finishedWork = renderRoot(root, expirationTime);
@@ -1558,7 +1614,7 @@ export default function(
// before committing.
if (!shouldYield()) {
// Still time left. Commit the root.
- root.remainingExpirationTime = commitRoot(finishedWork);
+ completeRoot(root, finishedWork, expirationTime);
} else {
// There's no time left. Mark this root as complete. We'll come
// back and commit it later.
@@ -1571,6 +1627,33 @@ export default function(
isRendering = false;
}
+ function completeRoot(
+ root: FiberRoot,
+ finishedWork: Fiber,
+ expirationTime: ExpirationTime,
+ ): void {
+ // Check if there's a batch that matches this expiration time.
+ const firstBatch = root.firstBatch;
+ if (firstBatch !== null && firstBatch._expirationTime <= expirationTime) {
+ if (completedBatches === null) {
+ completedBatches = [firstBatch];
+ } else {
+ completedBatches.push(firstBatch);
+ }
+ if (firstBatch._defer) {
+ // This root is blocked from committing by a batch. Unschedule it until
+ // we receive another update.
+ root.finishedWork = finishedWork;
+ root.remainingExpirationTime = NoWork;
+ return;
+ }
+ }
+
+ // Commit the root.
+ root.finishedWork = null;
+ root.remainingExpirationTime = commitRoot(finishedWork);
+ }
+
// When working on async work, the reconciler asks the renderer if it should
// yield execution. For DOM, we implement this with requestIdleCallback.
function shouldYield() {
@@ -1654,9 +1737,12 @@ export default function(
computeAsyncExpiration,
computeExpirationForFiber,
scheduleWork,
+ requestWork,
+ flushRoot,
batchedUpdates,
unbatchedUpdates,
flushSync,
deferredUpdates,
+ computeUniqueAsyncExpiration,
};
}
diff --git a/packages/react-test-renderer/src/ReactTestRenderer.js b/packages/react-test-renderer/src/ReactTestRenderer.js
index c68516d31d51f..a96dfbc368b68 100644
--- a/packages/react-test-renderer/src/ReactTestRenderer.js
+++ b/packages/react-test-renderer/src/ReactTestRenderer.js
@@ -607,7 +607,7 @@ var ReactTestRendererFiber = {
if (root == null || root.current == null) {
return;
}
- TestRenderer.updateContainer(null, root, null);
+ TestRenderer.updateContainer(null, root, null, null);
container = null;
root = null;
},