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; },