From 3ad0aa4ae17cbf5e5642c819dc8c5b71a6cfa203 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Mon, 11 Apr 2016 13:18:53 +0100 Subject: [PATCH 1/2] Track mounted instances with ReactDebugInstanceMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a follow-up to #6389, also extracted from #6046. In #6046, we added a new API for associating internal instances with debug-time IDs. Third-party tools such as React DevTools and ReactPerf would use those IDs to query information about the instances, rather than use the instances directly. Here, we add the integration of `ReactDebugInstanceMap` into `ReactDOM` and `ReactDOMServer` rendering. The instances are registered during instantiation because we need to know their IDs right away: some events, such as “we are calling the constructor”, may fire before the instance is mounted. Currently we unregister instances when they get unmounted but in the future it will be possible to separate unmounting from unregistration, in case we ever want to support reparenting. One notable change here is that we now call `unmountComponent()` for server rendering in `__DEV__`. Traditionally this has not been necessary, as server rendering code path in mounting doesn’t allocate any resources it needs to release. However, now that we track every component instantiation, we also want to remove this information, so this requires a real unmounting pass on the server. Nevertheless, since this change is only relevant for `__DEV__`, it does not affect the production performance. Some existing code in `unmountComponent()` used to rely on the fact that it’s only called on the client. To mitigate this, I added a flag called `hasReactMountReady` to the transactions. It indicates whether `getReactMountReady()` is a real queue or a no-op. This way, component can tell, for example, whether it needs to run `componentWillUnmount()`, or whether it needs to ensure the node is allowed to be unmounted, such as in case of ``. One concern I have is that conditionally running `unmountComponent()` for server rendering can make it easy to introduce accidental memory leaks, as it will always run in the test environment but not in production. An alternative option would be to add a separate method called `releaseComponent()` that serves as `unmountComponent()` during server rendering only. This way relying on `unmountComponent()` deallocating a resource will be easy to spot because `unmountComponent()` still won’t run in tests for server rendering. Another concern is that we’re finally using `ReactDebugInstanceMap` in the code which means we now have a dependency on WeakMap being available in `__DEV__` builds. If this is bad, we should do something about it. --- src/isomorphic/ReactDebugInstanceMap.js | 4 + src/isomorphic/ReactDebugTool.js | 6 + .../__tests__/ReactDebugInstanceMap-test.js | 117 ++++++++++++++++++ src/renderers/dom/client/ReactMount.js | 6 +- .../dom/client/ReactReconcileTransaction.js | 1 + .../dom/server/ReactServerRendering.js | 10 +- .../server/ReactServerRenderingTransaction.js | 1 + src/renderers/dom/shared/ReactDOMComponent.js | 12 +- .../shared/reconciler/ReactChildReconciler.js | 8 +- .../reconciler/ReactCompositeComponent.js | 22 ++-- .../shared/reconciler/ReactMultiChild.js | 12 +- .../shared/reconciler/ReactReconciler.js | 8 +- .../reconciler/ReactSimpleEmptyComponent.js | 4 +- .../reconciler/instantiateReactComponent.js | 5 + src/test/ReactTestUtils.js | 8 +- 15 files changed, 190 insertions(+), 34 deletions(-) diff --git a/src/isomorphic/ReactDebugInstanceMap.js b/src/isomorphic/ReactDebugInstanceMap.js index 50dddf4ad0e..0bbce483e33 100644 --- a/src/isomorphic/ReactDebugInstanceMap.js +++ b/src/isomorphic/ReactDebugInstanceMap.js @@ -86,15 +86,18 @@ var ReactDebugInstanceMap = { } return getIDForInstance(internalInstance); }, + getInstanceByID(instanceID) { return getInstanceByID(instanceID); }, + isRegisteredInstance(internalInstance) { if (!checkValidInstance(internalInstance)) { return false; } return isRegisteredInstance(internalInstance); }, + registerInstance(internalInstance) { if (!checkValidInstance(internalInstance)) { return; @@ -107,6 +110,7 @@ var ReactDebugInstanceMap = { ); registerInstance(internalInstance); }, + unregisterInstance(internalInstance) { if (!checkValidInstance(internalInstance)) { return; diff --git a/src/isomorphic/ReactDebugTool.js b/src/isomorphic/ReactDebugTool.js index 4d2c2a3536a..04c458647e0 100644 --- a/src/isomorphic/ReactDebugTool.js +++ b/src/isomorphic/ReactDebugTool.js @@ -11,6 +11,7 @@ 'use strict'; +var ReactDebugInstanceMap = require('ReactDebugInstanceMap'); var ReactInvalidSetStateWarningDevTool = require('ReactInvalidSetStateWarningDevTool'); var warning = require('warning'); @@ -58,6 +59,10 @@ var ReactDebugTool = { onSetState() { emitEvent('onSetState'); }, + onInstantiateComponent(internalInstance) { + ReactDebugInstanceMap.registerInstance(internalInstance); + emitEvent('onInstantiateComponent', internalInstance); + }, onMountRootComponent(internalInstance) { emitEvent('onMountRootComponent', internalInstance); }, @@ -69,6 +74,7 @@ var ReactDebugTool = { }, onUnmountComponent(internalInstance) { emitEvent('onUnmountComponent', internalInstance); + ReactDebugInstanceMap.unregisterInstance(internalInstance); }, }; diff --git a/src/isomorphic/__tests__/ReactDebugInstanceMap-test.js b/src/isomorphic/__tests__/ReactDebugInstanceMap-test.js index d9a063e2c64..40a86260808 100644 --- a/src/isomorphic/__tests__/ReactDebugInstanceMap-test.js +++ b/src/isomorphic/__tests__/ReactDebugInstanceMap-test.js @@ -15,12 +15,18 @@ describe('ReactDebugInstanceMap', function() { var React; var ReactDebugInstanceMap; var ReactDOM; + var ReactDOMComponentTree; + var ReactDOMServer; + var ReactInstanceMap; beforeEach(function() { jest.resetModuleRegistry(); React = require('React'); ReactDebugInstanceMap = require('ReactDebugInstanceMap'); ReactDOM = require('ReactDOM'); + ReactDOMComponentTree = require('ReactDOMComponentTree'); + ReactDOMServer = require('ReactDOMServer'); + ReactInstanceMap = require('ReactInstanceMap'); }); function createStubInstance() { @@ -170,4 +176,115 @@ describe('ReactDebugInstanceMap', function() { ); } }); + + describe('integration', () => { + describe('ReactDOM', () => { + it('registers native components', () => { + var div = document.createElement('div'); + + var spanInst; + ReactDOM.render( + { + if (span) { + spanInst = ReactDOMComponentTree.getInstanceFromNode(span); + } + }} />, + div + ); + expect(ReactDebugInstanceMap.isRegisteredInstance(spanInst)).toBe(true); + + var pInst; + ReactDOM.render( +

{ + if (p) { + pInst = ReactDOMComponentTree.getInstanceFromNode(p); + } + }} />, + div + ); + expect(ReactDebugInstanceMap.isRegisteredInstance(spanInst)).toBe(false); + expect(ReactDebugInstanceMap.isRegisteredInstance(pInst)).toBe(true); + + ReactDOM.unmountComponentAtNode(div); + expect(ReactDebugInstanceMap.isRegisteredInstance(spanInst)).toBe(false); + expect(ReactDebugInstanceMap.isRegisteredInstance(pInst)).toBe(false); + }); + + it('registers composite components', () => { + var fooInst; + var Foo = React.createClass({ + render() { + fooInst = ReactInstanceMap.get(this); + return null; + }, + }); + var barInst; + var Bar = React.createClass({ + render() { + barInst = ReactInstanceMap.get(this); + return null; + }, + }); + var div = document.createElement('div'); + + ReactDOM.render(, div); + expect(ReactDebugInstanceMap.isRegisteredInstance(fooInst)).toBe(true); + + ReactDOM.render(, div); + expect(ReactDebugInstanceMap.isRegisteredInstance(fooInst)).toBe(false); + expect(ReactDebugInstanceMap.isRegisteredInstance(barInst)).toBe(true); + + ReactDOM.unmountComponentAtNode(div); + expect(ReactDebugInstanceMap.isRegisteredInstance(fooInst)).toBe(false); + expect(ReactDebugInstanceMap.isRegisteredInstance(barInst)).toBe(false); + }); + }); + }); + + describe('ReactDOMServer', () => { + it('registers components but unregisters them in the end', () => { + var fooInst; + var Foo = React.createClass({ + render() { + fooInst = ReactInstanceMap.get(this); + expect(ReactDebugInstanceMap.isRegisteredInstance(fooInst)).toBe(true); + return null; + }, + }); + + ReactDOMServer.renderToString(); + expect(ReactDebugInstanceMap.isRegisteredInstance(fooInst)).toBe(false); + + ReactDOMServer.renderToStaticMarkup(); + expect(ReactDebugInstanceMap.isRegisteredInstance(fooInst)).toBe(false); + }); + + it('can be used together with ReactDOM', () => { + var fooInst; + var Foo = React.createClass({ + render() { + fooInst = ReactInstanceMap.get(this); + expect(ReactDebugInstanceMap.isRegisteredInstance(fooInst)).toBe(true); + return null; + }, + }); + var barInst; + var Bar = React.createClass({ + render() { + barInst = ReactInstanceMap.get(this); + expect(ReactDebugInstanceMap.isRegisteredInstance(barInst)).toBe(true); + return

{ReactDOMServer.renderToString()}
; + }, + }); + + var div = document.createElement('div'); + ReactDOM.render(, div); + expect(ReactDebugInstanceMap.isRegisteredInstance(fooInst)).toBe(false); + expect(ReactDebugInstanceMap.isRegisteredInstance(barInst)).toBe(true); + + ReactDOM.unmountComponentAtNode(div); + expect(ReactDebugInstanceMap.isRegisteredInstance(fooInst)).toBe(false); + expect(ReactDebugInstanceMap.isRegisteredInstance(barInst)).toBe(false); + }); + }); }); diff --git a/src/renderers/dom/client/ReactMount.js b/src/renderers/dom/client/ReactMount.js index 7f6f8cac6ff..a1c420fbf7d 100644 --- a/src/renderers/dom/client/ReactMount.js +++ b/src/renderers/dom/client/ReactMount.js @@ -170,7 +170,11 @@ function batchedMountComponentIntoNode( * @see {ReactMount.unmountComponentAtNode} */ function unmountComponentFromNode(instance, container, safely) { - ReactReconciler.unmountComponent(instance, safely); + var transaction = ReactUpdates.ReactReconcileTransaction.getPooled( + /* useCreateElement */ true + ); + ReactReconciler.unmountComponent(instance, transaction, safely); + ReactUpdates.ReactReconcileTransaction.release(transaction); if (container.nodeType === DOC_NODE_TYPE) { container = container.documentElement; diff --git a/src/renderers/dom/client/ReactReconcileTransaction.js b/src/renderers/dom/client/ReactReconcileTransaction.js index fedd883bf1c..7b54019ec8f 100644 --- a/src/renderers/dom/client/ReactReconcileTransaction.js +++ b/src/renderers/dom/client/ReactReconcileTransaction.js @@ -113,6 +113,7 @@ function ReactReconcileTransaction(useCreateElement) { // `ReactTextComponent` checks it in `mountComponent`.` this.renderToStaticMarkup = false; this.reactMountReady = CallbackQueue.getPooled(null); + this.hasReactMountReady = true; this.useCreateElement = useCreateElement; } diff --git a/src/renderers/dom/server/ReactServerRendering.js b/src/renderers/dom/server/ReactServerRendering.js index 5a5d604b7dd..25c45748750 100644 --- a/src/renderers/dom/server/ReactServerRendering.js +++ b/src/renderers/dom/server/ReactServerRendering.js @@ -14,6 +14,7 @@ var ReactDOMContainerInfo = require('ReactDOMContainerInfo'); var ReactDefaultBatchingStrategy = require('ReactDefaultBatchingStrategy'); var ReactElement = require('ReactElement'); var ReactMarkupChecksum = require('ReactMarkupChecksum'); +var ReactReconciler = require('ReactReconciler'); var ReactServerBatchingStrategy = require('ReactServerBatchingStrategy'); var ReactServerRenderingTransaction = require('ReactServerRenderingTransaction'); @@ -36,12 +37,19 @@ function renderToStringImpl(element, makeStaticMarkup) { return transaction.perform(function() { var componentInstance = instantiateReactComponent(element); - var markup = componentInstance.mountComponent( + var markup = ReactReconciler.mountComponent( + componentInstance, transaction, null, ReactDOMContainerInfo(), emptyObject ); + if (__DEV__) { + // We unmount in __DEV__ to clean up the ReactDebugInstanceMap + // registrations that occur during instance construction. + // We don't do this pass in prod because we don't use it there. + ReactReconciler.unmountComponent(componentInstance, transaction); + } if (!makeStaticMarkup) { markup = ReactMarkupChecksum.addChecksumToMarkup(markup); } diff --git a/src/renderers/dom/server/ReactServerRenderingTransaction.js b/src/renderers/dom/server/ReactServerRenderingTransaction.js index 176a550c599..c56aa86f347 100644 --- a/src/renderers/dom/server/ReactServerRenderingTransaction.js +++ b/src/renderers/dom/server/ReactServerRenderingTransaction.js @@ -34,6 +34,7 @@ function ReactServerRenderingTransaction(renderToStaticMarkup) { this.reinitializeTransaction(); this.renderToStaticMarkup = renderToStaticMarkup; this.useCreateElement = false; + this.hasReactMountReady = false; } var Mixin = { diff --git a/src/renderers/dom/shared/ReactDOMComponent.js b/src/renderers/dom/shared/ReactDOMComponent.js index c897cb30efc..36f23beafaa 100644 --- a/src/renderers/dom/shared/ReactDOMComponent.js +++ b/src/renderers/dom/shared/ReactDOMComponent.js @@ -991,16 +991,16 @@ ReactDOMComponent.Mixin = { if (lastChildren != null && nextChildren == null) { this.updateChildren(null, transaction, context); } else if (lastHasContentOrHtml && !nextHasContentOrHtml) { - this.updateTextContent(''); + this.updateTextContent('', transaction); } if (nextContent != null) { if (lastContent !== nextContent) { - this.updateTextContent('' + nextContent); + this.updateTextContent('' + nextContent, transaction); } } else if (nextHtml != null) { if (lastHtml !== nextHtml) { - this.updateMarkup('' + nextHtml); + this.updateMarkup('' + nextHtml, transaction); } } else if (nextChildren != null) { this.updateChildren(nextChildren, transaction, context); @@ -1017,7 +1017,7 @@ ReactDOMComponent.Mixin = { * * @internal */ - unmountComponent: function(safely) { + unmountComponent: function(transaction, safely) { switch (this._tag) { case 'iframe': case 'object': @@ -1042,7 +1042,7 @@ ReactDOMComponent.Mixin = { * management. So we just document it and throw in dangerous cases. */ invariant( - false, + !transaction.hasReactMountReady, '<%s> tried to unmount. Because of cross-browser quirks it is ' + 'impossible to unmount some top-level components (eg , ' + ', and ) reliably and efficiently. To fix this, have a ' + @@ -1053,7 +1053,7 @@ ReactDOMComponent.Mixin = { break; } - this.unmountChildren(safely); + this.unmountChildren(transaction, safely); ReactDOMComponentTree.uncacheNode(this); EventPluginHub.deleteAllListeners(this); ReactComponentBrowserEnvironment.unmountIDFromEnvironment(this._rootNodeID); diff --git a/src/renderers/shared/reconciler/ReactChildReconciler.js b/src/renderers/shared/reconciler/ReactChildReconciler.js index 634b3444c71..e35f7b2cc29 100644 --- a/src/renderers/shared/reconciler/ReactChildReconciler.js +++ b/src/renderers/shared/reconciler/ReactChildReconciler.js @@ -100,7 +100,7 @@ var ReactChildReconciler = { } else { if (prevChild) { removedNodes[name] = ReactReconciler.getNativeNode(prevChild); - ReactReconciler.unmountComponent(prevChild, false); + ReactReconciler.unmountComponent(prevChild, transaction, false); } // The child must be instantiated before it's mounted. var nextChildInstance = instantiateReactComponent(nextElement); @@ -113,7 +113,7 @@ var ReactChildReconciler = { !(nextChildren && nextChildren.hasOwnProperty(name))) { prevChild = prevChildren[name]; removedNodes[name] = ReactReconciler.getNativeNode(prevChild); - ReactReconciler.unmountComponent(prevChild, false); + ReactReconciler.unmountComponent(prevChild, transaction, false); } } }, @@ -125,11 +125,11 @@ var ReactChildReconciler = { * @param {?object} renderedChildren Previously initialized set of children. * @internal */ - unmountChildren: function(renderedChildren, safely) { + unmountChildren: function(renderedChildren, transaction, safely) { for (var name in renderedChildren) { if (renderedChildren.hasOwnProperty(name)) { var renderedChild = renderedChildren[name]; - ReactReconciler.unmountComponent(renderedChild, safely); + ReactReconciler.unmountComponent(renderedChild, transaction, safely); } } }, diff --git a/src/renderers/shared/reconciler/ReactCompositeComponent.js b/src/renderers/shared/reconciler/ReactCompositeComponent.js index 0472e1fa86e..23ba73edcad 100644 --- a/src/renderers/shared/reconciler/ReactCompositeComponent.js +++ b/src/renderers/shared/reconciler/ReactCompositeComponent.js @@ -343,7 +343,7 @@ var ReactCompositeComponentMixin = { } checkpoint = transaction.checkpoint(); - this._renderedComponent.unmountComponent(true); + ReactReconciler.unmountComponent(this._renderedComponent, transaction, true); transaction.rollback(checkpoint); // Try again - we've informed the component about the error, so they can render an error message this time. @@ -395,23 +395,25 @@ var ReactCompositeComponentMixin = { * @final * @internal */ - unmountComponent: function(safely) { + unmountComponent: function(transaction, safely) { if (!this._renderedComponent) { return; } var inst = this._instance; - if (inst.componentWillUnmount) { - if (safely) { - var name = this.getName() + '.componentWillUnmount()'; - ReactErrorUtils.invokeGuardedCallback(name, inst.componentWillUnmount.bind(inst)); - } else { - inst.componentWillUnmount(); + if (transaction.hasReactMountReady) { + if (inst.componentWillUnmount) { + if (safely) { + var name = this.getName() + '.componentWillUnmount()'; + ReactErrorUtils.invokeGuardedCallback(name, inst.componentWillUnmount.bind(inst)); + } else { + inst.componentWillUnmount(); + } } } if (this._renderedComponent) { - ReactReconciler.unmountComponent(this._renderedComponent, safely); + ReactReconciler.unmountComponent(this._renderedComponent, transaction, safely); this._renderedNodeType = null; this._renderedComponent = null; this._instance = null; @@ -843,7 +845,7 @@ var ReactCompositeComponentMixin = { ); } else { var oldNativeNode = ReactReconciler.getNativeNode(prevComponentInstance); - ReactReconciler.unmountComponent(prevComponentInstance, false); + ReactReconciler.unmountComponent(prevComponentInstance, transaction, false); this._renderedNodeType = ReactNodeTypes.getType(nextRenderedElement); this._renderedComponent = this._instantiateReactComponent( diff --git a/src/renderers/shared/reconciler/ReactMultiChild.js b/src/renderers/shared/reconciler/ReactMultiChild.js index 66f38613218..65bd1832ac7 100644 --- a/src/renderers/shared/reconciler/ReactMultiChild.js +++ b/src/renderers/shared/reconciler/ReactMultiChild.js @@ -239,10 +239,10 @@ var ReactMultiChild = { * @param {string} nextContent String of content. * @internal */ - updateTextContent: function(nextContent) { + updateTextContent: function(nextContent, transaction) { var prevChildren = this._renderedChildren; // Remove any rendered children. - ReactChildReconciler.unmountChildren(prevChildren, false); + ReactChildReconciler.unmountChildren(prevChildren, transaction, false); for (var name in prevChildren) { if (prevChildren.hasOwnProperty(name)) { invariant(false, 'updateTextContent called on non-empty component.'); @@ -259,10 +259,10 @@ var ReactMultiChild = { * @param {string} nextMarkup String of markup. * @internal */ - updateMarkup: function(nextMarkup) { + updateMarkup: function(nextMarkup, transaction) { var prevChildren = this._renderedChildren; // Remove any rendered children. - ReactChildReconciler.unmountChildren(prevChildren, false); + ReactChildReconciler.unmountChildren(prevChildren, transaction, false); for (var name in prevChildren) { if (prevChildren.hasOwnProperty(name)) { invariant(false, 'updateTextContent called on non-empty component.'); @@ -366,9 +366,9 @@ var ReactMultiChild = { * * @internal */ - unmountChildren: function(safely) { + unmountChildren: function(transaction, safely) { var renderedChildren = this._renderedChildren; - ReactChildReconciler.unmountChildren(renderedChildren, safely); + ReactChildReconciler.unmountChildren(renderedChildren, transaction, safely); this._renderedChildren = null; }, diff --git a/src/renderers/shared/reconciler/ReactReconciler.js b/src/renderers/shared/reconciler/ReactReconciler.js index ae7806e4fc0..6a6b8cfca0e 100644 --- a/src/renderers/shared/reconciler/ReactReconciler.js +++ b/src/renderers/shared/reconciler/ReactReconciler.js @@ -72,9 +72,11 @@ var ReactReconciler = { * @final * @internal */ - unmountComponent: function(internalInstance, safely) { - ReactRef.detachRefs(internalInstance, internalInstance._currentElement); - internalInstance.unmountComponent(safely); + unmountComponent: function(internalInstance, transaction, safely) { + if (transaction.hasReactMountReady) { + ReactRef.detachRefs(internalInstance, internalInstance._currentElement); + } + internalInstance.unmountComponent(transaction, safely); if (__DEV__) { ReactInstrumentation.debugTool.onUnmountComponent(internalInstance); } diff --git a/src/renderers/shared/reconciler/ReactSimpleEmptyComponent.js b/src/renderers/shared/reconciler/ReactSimpleEmptyComponent.js index 0565a016441..e62042cb288 100644 --- a/src/renderers/shared/reconciler/ReactSimpleEmptyComponent.js +++ b/src/renderers/shared/reconciler/ReactSimpleEmptyComponent.js @@ -38,8 +38,8 @@ Object.assign(ReactSimpleEmptyComponent.prototype, { getNativeNode: function() { return ReactReconciler.getNativeNode(this._renderedComponent); }, - unmountComponent: function() { - ReactReconciler.unmountComponent(this._renderedComponent); + unmountComponent: function(transaction) { + ReactReconciler.unmountComponent(this._renderedComponent, transaction); this._renderedComponent = null; }, }); diff --git a/src/renderers/shared/reconciler/instantiateReactComponent.js b/src/renderers/shared/reconciler/instantiateReactComponent.js index 796e037b995..738cf9b9a24 100644 --- a/src/renderers/shared/reconciler/instantiateReactComponent.js +++ b/src/renderers/shared/reconciler/instantiateReactComponent.js @@ -13,6 +13,7 @@ var ReactCompositeComponent = require('ReactCompositeComponent'); var ReactEmptyComponent = require('ReactEmptyComponent'); +var ReactInstrumentation = require('ReactInstrumentation'); var ReactNativeComponent = require('ReactNativeComponent'); var invariant = require('invariant'); @@ -129,6 +130,10 @@ function instantiateReactComponent(node) { } } + if (__DEV__) { + ReactInstrumentation.debugTool.onInstantiateComponent(instance); + } + return instance; } diff --git a/src/test/ReactTestUtils.js b/src/test/ReactTestUtils.js index d1365b50c54..2320cec36d9 100644 --- a/src/test/ReactTestUtils.js +++ b/src/test/ReactTestUtils.js @@ -23,6 +23,7 @@ var ReactElement = require('ReactElement'); var ReactBrowserEventEmitter = require('ReactBrowserEventEmitter'); var ReactCompositeComponent = require('ReactCompositeComponent'); var ReactInstanceMap = require('ReactInstanceMap'); +var ReactInstrumentation = require('ReactInstrumentation'); var ReactUpdates = require('ReactUpdates'); var SyntheticEvent = require('SyntheticEvent'); @@ -369,6 +370,9 @@ ReactShallowRenderer.prototype.getMountedInstance = function() { var NoopInternalComponent = function(element) { this._renderedOutput = element; this._currentElement = element; + if (__DEV__) { + ReactInstrumentation.debugTool.onInstantiateComponent(this); + } }; NoopInternalComponent.prototype = { @@ -455,7 +459,9 @@ ReactShallowRenderer.prototype.getRenderOutput = function() { ReactShallowRenderer.prototype.unmount = function() { if (this._instance) { - this._instance.unmountComponent(false); + var transaction = ReactUpdates.ReactReconcileTransaction.getPooled(true); + this._instance.unmountComponent(transaction, false); + ReactUpdates.ReactReconcileTransaction.release(transaction); } }; From b59de685f1b4b347699af99718d17a30f1baff24 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Mon, 11 Apr 2016 18:42:50 +0100 Subject: [PATCH 2/2] Add ReactDebugIntrospection This is a follow-up to #6486, also extracted from #6046. Here, we add an introspection API that will let React DevTools, ReactPerf, and third party tools query information about the internal instances without exposing them. --- src/isomorphic/ReactDebugIntrospection.js | 166 +++++ .../__tests__/ReactDebugIntrospection-test.js | 570 ++++++++++++++++++ 2 files changed, 736 insertions(+) create mode 100644 src/isomorphic/ReactDebugIntrospection.js create mode 100644 src/isomorphic/__tests__/ReactDebugIntrospection-test.js diff --git a/src/isomorphic/ReactDebugIntrospection.js b/src/isomorphic/ReactDebugIntrospection.js new file mode 100644 index 00000000000..9784ce1c383 --- /dev/null +++ b/src/isomorphic/ReactDebugIntrospection.js @@ -0,0 +1,166 @@ +/** + * Copyright 2016-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactDebugIntrospection + */ + +'use strict'; + +var ReactDebugInstanceMap = require('ReactDebugInstanceMap'); + +function getChildren(instance) { + if (instance._renderedComponent) { + if (instance._renderedComponent._currentElement) { + return [instance._renderedComponent]; + } else { + return []; + } + } else if (instance._renderedChildren) { + var children = []; + for (var key in instance._renderedChildren) { + children.push(instance._renderedChildren[key]); + } + return children; + } else { + return []; + } +} + +function getDisplayName(instance) { + var element = instance._currentElement; + if (element == null) { + return '#empty'; + } else if (typeof element === 'string' || typeof element === 'number') { + return '#text'; + } else if (typeof element.type === 'string') { + return element.type; + } else if (instance.getName) { + return instance.getName() || 'Unknown'; + } else { + return element.type.displayName || element.type.name || 'Unknown'; + } +} + +function getOwner(instance) { + var element = instance._currentElement; + if (element == null) { + return null; + } + return element._owner; +} + +function isComposite(instance) { + var element = instance._currentElement; + if (element == null) { + return false; + } + return typeof element.type === 'function'; +} + +var INLINED_CHILDREN_TYPES = {'string': true, 'number': true}; +var INLINE_ID_PREFIX = 'text:'; + +function createInlineTextID(parentInstanceID) { + return `${INLINE_ID_PREFIX}${parentInstanceID}`; +} +function isInlineTextID(instanceID) { + return instanceID.substr(0, INLINE_ID_PREFIX.length) === INLINE_ID_PREFIX; +} +function getParentInstanceIDOfInlineTextID(instanceID) { + return instanceID.substr(INLINE_ID_PREFIX.length); +} + +function getInlineText(instance) { + var element = instance._currentElement; + var props = element && element.props; + if (props && INLINED_CHILDREN_TYPES[typeof props.children]) { + return props.children.toString(); + } +} + +var ReactDebugIntrospection = { + getChildIDs(instanceID) { + var instance = ReactDebugInstanceMap.getInstanceByID(instanceID); + if (!instance) { + return []; + } + + var childInstances = getChildren(instance); + var childIDs = childInstances.map(ReactDebugInstanceMap.getIDForInstance); + + // DOM components inline a single child. + // It doesn't correspond to a real instance but we pretend that it does + // because this is an implementation detail of the DOM renderer. + if (!childIDs.length && getInlineText(instance)) { + var inlineChildID = createInlineTextID(instanceID); + childIDs.push(inlineChildID); + } + + return childIDs; + }, + + getDisplayName(instanceID) { + var instance = ReactDebugInstanceMap.getInstanceByID(instanceID); + if (!instance) { + if (isInlineTextID(instanceID)) { + return '#text'; + } else { + return 'Unknown'; + } + } + return getDisplayName(instance); + }, + + getText(instanceID) { + var instance = ReactDebugInstanceMap.getInstanceByID(instanceID); + if (instance) { + return instance._stringText; + } + + if (isInlineTextID(instanceID)) { + var parentInstanceID = getParentInstanceIDOfInlineTextID(instanceID); + var parentInstance = ReactDebugInstanceMap.getInstanceByID( + parentInstanceID + ); + if (parentInstance) { + return getInlineText(parentInstance); + } + } + }, + + isComposite(instanceID) { + var instance = ReactDebugInstanceMap.getInstanceByID(instanceID); + if (!instance) { + return false; + } + return isComposite(instance); + }, + + // TODO: make the new ReactPerf depend on element source location instead. + unstable_getOwnerID(instanceID) { + // Note: this method can't determine the owner for text components + // because they are not created from real elements. + var instance = ReactDebugInstanceMap.getInstanceByID(instanceID); + if (!instance) { + return null; + } + var owner = getOwner(instance); + if (!owner) { + return null; + } + return ReactDebugInstanceMap.getIDForInstance(owner); + }, + + // TODO: stop exposing this method when ReactDebugIntrospection provides all + // functions that React DevTools need without exposing internal instances. + unstable_getInternalInstanceByID(instanceID) { + return ReactDebugInstanceMap.getInstanceByID(instanceID); + }, +}; + +module.exports = ReactDebugIntrospection; diff --git a/src/isomorphic/__tests__/ReactDebugIntrospection-test.js b/src/isomorphic/__tests__/ReactDebugIntrospection-test.js new file mode 100644 index 00000000000..311d65a74f5 --- /dev/null +++ b/src/isomorphic/__tests__/ReactDebugIntrospection-test.js @@ -0,0 +1,570 @@ +/** + * Copyright 2016-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails react-core + */ + +'use strict'; + +describe('ReactDebugIntrospection', () => { + var React; + var ReactDebugInstanceMap; + var ReactDebugIntrospection; + var ReactDOM; + var ReactInstanceMap; + + beforeEach(() => { + jest.resetModuleRegistry(); + + React = require('React'); + ReactDebugInstanceMap = require('ReactDebugInstanceMap'); + ReactDebugIntrospection = require('ReactDebugIntrospection'); + ReactDOM = require('ReactDOM'); + ReactInstanceMap = require('ReactInstanceMap'); + }); + + function getTree(instanceID, includeOwner) { + var text = ReactDebugIntrospection.getText(instanceID); + var result = { + isComposite: ReactDebugIntrospection.isComposite(instanceID), + displayName: ReactDebugIntrospection.getDisplayName(instanceID), + children: ReactDebugIntrospection.getChildIDs(instanceID).map(childID => + getTree(childID, includeOwner) + ), + }; + if (text !== undefined) { + result.text = text; + } + if (includeOwner) { + // Owner is going to be removed eventually so we only calculate it + // for a few focused tests that can later be easily removed. + var ownerID = ReactDebugIntrospection.unstable_getOwnerID(instanceID); + if (ownerID) { + var ownerDisplayName = ReactDebugIntrospection.getDisplayName(ownerID); + if (ownerDisplayName) { + result.ownerDisplayName = ownerDisplayName; + } + } + } + return result; + } + + function assertTreeMatches(element, expectedTree, includeOwner) { + var wrapperInst; + class Wrapper extends React.Component { + render() { + wrapperInst = ReactInstanceMap.get(this); + return element; + } + } + + var div = document.createElement('div'); + ReactDOM.render(, div); + var instanceID = ReactDebugInstanceMap.getIDForInstance(wrapperInst); + var tree = getTree(instanceID, includeOwner); + expect(tree.children[0]).toEqual(expectedTree); + + ReactDOM.unmountComponentAtNode(div); + tree = getTree(instanceID, includeOwner); + expect(tree).toEqual({ + isComposite: false, + displayName: 'Unknown', + children: [], + }); + } + + it('returns sane defaults for unknown IDs', () => { + expect(ReactDebugIntrospection.getText('fake')).toBe(undefined); + expect(ReactDebugIntrospection.isComposite('fake')).toBe(false); + expect(ReactDebugIntrospection.getDisplayName('fake')).toBe('Unknown'); + expect(ReactDebugIntrospection.getChildIDs('fake')).toEqual([]); + expect(ReactDebugIntrospection.unstable_getOwnerID('fake')).toEqual(null); + }); + + it('uses displayName or Unknown for classic components', () => { + var Foo = React.createClass({ + render() { + return null; + }, + }); + Foo.displayName = 'Bar'; + var Baz = React.createClass({ + render() { + return null; + }, + }); + var Qux = React.createClass({ + render() { + return null; + }, + }); + delete Qux.displayName; + + var element =
; + var expectedTree = { + isComposite: false, + displayName: 'div', + children: [{ + isComposite: true, + displayName: 'Bar', + children: [], + }, { + isComposite: true, + displayName: 'Baz', + children: [], + }, { + isComposite: true, + displayName: 'Unknown', + children: [], + }], + }; + assertTreeMatches(element, expectedTree); + }); + + it('uses displayName, name, or ReactComponent for modern components', () => { + class Foo extends React.Component { + render() { + return null; + } + } + Foo.displayName = 'Bar'; + class Baz extends React.Component { + render() { + return null; + } + } + class Qux extends React.Component { + render() { + return null; + } + } + delete Qux.name; + + var element =
; + var expectedTree = { + isComposite: false, + displayName: 'div', + children: [{ + isComposite: true, + displayName: 'Bar', + children: [], + }, { + isComposite: true, + displayName: 'Baz', + children: [], + }, { + isComposite: true, + // Note: Ideally fallback name should be consistent (e.g. "Unknown") + displayName: 'ReactComponent', + children: [], + }], + }; + assertTreeMatches(element, expectedTree); + }); + + it('uses displayName, name, or Object for factory components', () => { + function Foo() { + return { + render() { + return null; + }, + }; + } + Foo.displayName = 'Bar'; + function Baz() { + return { + render() { + return null; + }, + }; + } + function Qux() { + return { + render() { + return null; + }, + }; + } + delete Qux.name; + + var element =
; + var expectedTree = { + isComposite: false, + displayName: 'div', + children: [{ + isComposite: true, + displayName: 'Bar', + children: [], + }, { + isComposite: true, + displayName: 'Baz', + children: [], + }, { + isComposite: true, + // Note: Ideally fallback name should be consistent (e.g. "Unknown") + displayName: 'Object', + children: [], + }], + }; + assertTreeMatches(element, expectedTree); + }); + + it('uses displayName, name, or StatelessComponent for functional components', () => { + function Foo() { + return null; + } + Foo.displayName = 'Bar'; + function Baz() { + return null; + } + function Qux() { + return null; + } + delete Qux.name; + + var element =
; + var expectedTree = { + isComposite: false, + displayName: 'div', + children: [{ + isComposite: true, + displayName: 'Bar', + children: [], + }, { + isComposite: true, + displayName: 'Baz', + children: [], + }, { + isComposite: true, + // Note: Ideally fallback name should be consistent (e.g. "Unknown") + displayName: 'StatelessComponent', + children: [], + }], + }; + assertTreeMatches(element, expectedTree); + }); + + it('reports a native tree correctly', () => { + var element = ( +
+

+ + Hi! + + Wow. +

+
+
+ ); + var expectedTree = { + isComposite: false, + displayName: 'div', + children: [{ + isComposite: false, + displayName: 'p', + children: [{ + isComposite: false, + displayName: 'span', + children: [{ + isComposite: false, + displayName: '#text', + text: 'Hi!', + children: [], + }], + }, { + isComposite: false, + displayName: '#text', + text: 'Wow.', + children: [], + }], + }, { + isComposite: false, + displayName: 'hr', + children: [], + }], + }; + assertTreeMatches(element, expectedTree); + }); + + it('reports a tree with composites correctly', () => { + var Qux = React.createClass({ + render() { + return null; + }, + }); + function Foo() { + return { + render() { + return ; + }, + }; + } + function Bar({children}) { + return

{children}

; + } + class Baz extends React.Component { + render() { + return ( +
+ + + Hi, + Mom + + Click me. +
+ ); + } + } + + var element = ; + var expectedTree = { + isComposite: true, + displayName: 'Baz', + children: [{ + isComposite: false, + displayName: 'div', + children: [{ + isComposite: true, + displayName: 'Foo', + children: [{ + isComposite: true, + displayName: 'Qux', + children: [], + }], + }, { + isComposite: true, + displayName: 'Bar', + children: [{ + isComposite: false, + displayName: 'h1', + children: [{ + isComposite: false, + displayName: 'span', + children: [{ + isComposite: false, + displayName: '#text', + text: 'Hi,', + children: [], + }], + }, { + isComposite: false, + displayName: '#text', + text: 'Mom', + children: [], + }], + }], + }, { + isComposite: false, + displayName: 'a', + children: [{ + isComposite: false, + displayName: '#text', + text: 'Click me.', + children: [], + }], + }], + }], + }; + assertTreeMatches(element, expectedTree); + }); + + it('ignores null children', () => { + class Foo extends React.Component { + render() { + return null; + } + } + var element = ; + var expectedTree = { + isComposite: true, + displayName: 'Foo', + children: [], + }; + assertTreeMatches(element, expectedTree); + }); + + it('ignores false children', () => { + class Foo extends React.Component { + render() { + return false; + } + } + var element = ; + var expectedTree = { + isComposite: true, + displayName: 'Foo', + children: [], + }; + assertTreeMatches(element, expectedTree); + }); + + it('reports text nodes as children', () => { + var element =
{'1'}{2}
; + var expectedTree = { + isComposite: false, + displayName: 'div', + children: [{ + isComposite: false, + displayName: '#text', + text: '1', + children: [], + }, { + isComposite: false, + displayName: '#text', + text: '2', + children: [], + }], + }; + assertTreeMatches(element, expectedTree); + }); + + it('reports a single text node as a child', () => { + var element =
{'1'}
; + var expectedTree = { + isComposite: false, + displayName: 'div', + children: [{ + isComposite: false, + displayName: '#text', + text: '1', + children: [], + }], + }; + assertTreeMatches(element, expectedTree); + }); + + it('reports a single number node as a child', () => { + var element =
{42}
; + var expectedTree = { + isComposite: false, + displayName: 'div', + children: [{ + isComposite: false, + displayName: '#text', + text: '42', + children: [], + }], + }; + assertTreeMatches(element, expectedTree); + }); + + it('does not get fooled by 0 as a text node', () => { + var element =
{0}
; + var expectedTree = { + isComposite: false, + displayName: 'div', + children: [{ + isComposite: false, + displayName: '#text', + text: '0', + children: [], + }], + }; + assertTreeMatches(element, expectedTree); + }); + + it('ignores empty text nodes', () => { + var element =
{''}
; + var expectedTree = { + isComposite: false, + displayName: 'div', + children: [], + }; + assertTreeMatches(element, expectedTree); + }); + + it('skips empty nodes for multiple children', () => { + function Foo() { + return
; + } + var element = ( +
+ {'hi'} + {false} + {42} + {null} + +
+ ); + var expectedTree = { + isComposite: false, + displayName: 'div', + children: [{ + isComposite: false, + displayName: '#text', + text: 'hi', + children: [], + }, { + isComposite: false, + displayName: '#text', + text: '42', + children: [], + }, { + isComposite: true, + displayName: 'Foo', + children: [{ + isComposite: false, + displayName: 'div', + children: [], + }], + }], + }; + assertTreeMatches(element, expectedTree); + }); + + it('tracks owner correctly', () => { + class Foo extends React.Component { + render() { + return

Hi.

; + } + } + function Bar({children}) { + return
{children} Mom
; + } + + // Note that owner is not calculated for text nodes + // because they are not created from real elements. + var element =
; + var expectedTree = { + isComposite: false, + displayName: 'article', + children: [{ + isComposite: true, + displayName: 'Foo', + children: [{ + isComposite: true, + displayName: 'Bar', + ownerDisplayName: 'Foo', + children: [{ + isComposite: false, + displayName: 'div', + ownerDisplayName: 'Bar', + children: [{ + isComposite: false, + displayName: 'h1', + ownerDisplayName: 'Foo', + children: [{ + isComposite: false, + displayName: '#text', + text: 'Hi.', + children: [], + }], + }, { + isComposite: false, + displayName: '#text', + text: ' Mom', + children: [], + }], + }], + }], + }], + }; + assertTreeMatches(element, expectedTree, true); + }); +});