From 575fb79162efa0f913c4fbd79960d4fc335e92cb Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Thu, 31 Mar 2016 17:58:53 +0100 Subject: [PATCH] Add ReactDebugInstanceMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is the first in a series of pull requests split from the new `ReactPerf` implementation in #6046. Here, we introduce a new module called `ReactDebugInstanceMap`. It will be used in `__DEV__` and, when the `__PROFILE__` gate is added, in the `__PROFILE__` builds. It will *not* be used in the production builds. This module acts as a mapping between “debug IDs” (a new concept) and the internal instances. Not to be confused with the existing `ReactInstanceMap` that maps internal instances to public instances. What are the “debug IDs” and why do we need them? Both the new `ReactPerf` and other consumers of the devtool API, such as React DevTools, need access to some data from the internal instances, such as the instance type display name, current props and children, and so on. Right now we let such tools access internal instances directly but this hurts our ability to refactor their implementations and burdens React DevTools with undesired implementation details such as having to support React ART in a special way.[1] The purpose of adding `ReactDebugInstanceMap` is to only expose “debug IDs” instead of the internal instances to any devtools. In a future RP, whenever there is an event such as mounting, updating, or unmounting a component, we will emit an event in `ReactDebugTool` with the debug ID of the instance. We will also add an introspection API that lets the consumer pass an ID and get the information about the current children, props, state, display name, and so on, without exposing the internal instances. `ReactDebugInstanceMap` has a concept of “registering” an instance. We plan to add the hooks that register an instance as soon as it is created, and unregister it during unmounting. It will only be possible to read information about the instance while it is still registered. If we add support for reparenting, we should be able to move the (un)registration code to different places in the component lifecycle without changing this code. The currently registered instances are held in the `registeredInstancesByIDs` dictionary. There is also a reverse lookup dictionary called `allInstancesToIDs` which maps instances back to their IDs. It is implemented as a `WeakMap` so the keys are stable and we’re not holding onto the unregistered instances. If we’re not happy with `WeakMap`, one possible alternative would be to add a new field called `_debugID` to all the internal instances, but we don’t want to do this in production. Using `WeakMap` seems like a simpler solution here (and stable IDs are a nice bonus). This, however, means that the `__DEV__` (and the future `__PROFILE__`) builds will only work in browsers that support our usage of `WeakMap`. [1]: https://github.com/facebook/react-devtools/blob/577ec9b8d994fd26d76feb20a1993a96558b7745/backend/getData.js --- src/isomorphic/ReactDebugInstanceMap.js | 124 +++++++++++++ .../__tests__/ReactDebugInstanceMap-test.js | 173 ++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 src/isomorphic/ReactDebugInstanceMap.js create mode 100644 src/isomorphic/__tests__/ReactDebugInstanceMap-test.js diff --git a/src/isomorphic/ReactDebugInstanceMap.js b/src/isomorphic/ReactDebugInstanceMap.js new file mode 100644 index 00000000000..50dddf4ad0e --- /dev/null +++ b/src/isomorphic/ReactDebugInstanceMap.js @@ -0,0 +1,124 @@ +/** + * 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 ReactDebugInstanceMap + */ + +'use strict'; + +var warning = require('warning'); + +function checkValidInstance(internalInstance) { + if (!internalInstance) { + warning( + false, + 'There is an internal error in the React developer tools integration. ' + + 'Instead of an internal instance, received %s. ' + + 'Please report this as a bug in React.', + internalInstance + ); + return false; + } + var isValid = typeof internalInstance.mountComponent === 'function'; + warning( + isValid, + 'There is an internal error in the React developer tools integration. ' + + 'Instead of an internal instance, received an object with the following ' + + 'keys: %s. Please report this as a bug in React.', + Object.keys(internalInstance).join(', ') + ); + return isValid; +} + +var idCounter = 1; +var instancesByIDs = {}; +var instancesToIDs; + +function getIDForInstance(internalInstance) { + if (!instancesToIDs) { + instancesToIDs = new WeakMap(); + } + if (instancesToIDs.has(internalInstance)) { + return instancesToIDs.get(internalInstance); + } else { + var instanceID = (idCounter++).toString(); + instancesToIDs.set(internalInstance, instanceID); + return instanceID; + } +} + +function getInstanceByID(instanceID) { + return instancesByIDs[instanceID] || null; +} + +function isRegisteredInstance(internalInstance) { + var instanceID = getIDForInstance(internalInstance); + if (instanceID) { + return instancesByIDs.hasOwnProperty(instanceID); + } else { + return false; + } +} + +function registerInstance(internalInstance) { + var instanceID = getIDForInstance(internalInstance); + if (instanceID) { + instancesByIDs[instanceID] = internalInstance; + } +} + +function unregisterInstance(internalInstance) { + var instanceID = getIDForInstance(internalInstance); + if (instanceID) { + delete instancesByIDs[instanceID]; + } +} + +var ReactDebugInstanceMap = { + getIDForInstance(internalInstance) { + if (!checkValidInstance(internalInstance)) { + return null; + } + return getIDForInstance(internalInstance); + }, + getInstanceByID(instanceID) { + return getInstanceByID(instanceID); + }, + isRegisteredInstance(internalInstance) { + if (!checkValidInstance(internalInstance)) { + return false; + } + return isRegisteredInstance(internalInstance); + }, + registerInstance(internalInstance) { + if (!checkValidInstance(internalInstance)) { + return; + } + warning( + !isRegisteredInstance(internalInstance), + 'There is an internal error in the React developer tools integration. ' + + 'A registered instance should not be registered again. ' + + 'Please report this as a bug in React.' + ); + registerInstance(internalInstance); + }, + unregisterInstance(internalInstance) { + if (!checkValidInstance(internalInstance)) { + return; + } + warning( + isRegisteredInstance(internalInstance), + 'There is an internal error in the React developer tools integration. ' + + 'An unregistered instance should not be unregistered again. ' + + 'Please report this as a bug in React.' + ); + unregisterInstance(internalInstance); + }, +}; + +module.exports = ReactDebugInstanceMap; diff --git a/src/isomorphic/__tests__/ReactDebugInstanceMap-test.js b/src/isomorphic/__tests__/ReactDebugInstanceMap-test.js new file mode 100644 index 00000000000..d9a063e2c64 --- /dev/null +++ b/src/isomorphic/__tests__/ReactDebugInstanceMap-test.js @@ -0,0 +1,173 @@ +/** + * 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('ReactDebugInstanceMap', function() { + var React; + var ReactDebugInstanceMap; + var ReactDOM; + + beforeEach(function() { + jest.resetModuleRegistry(); + React = require('React'); + ReactDebugInstanceMap = require('ReactDebugInstanceMap'); + ReactDOM = require('ReactDOM'); + }); + + function createStubInstance() { + return { mountComponent: () => {} }; + } + + it('should register and unregister instances', function() { + var inst1 = createStubInstance(); + var inst2 = createStubInstance(); + + expect(ReactDebugInstanceMap.isRegisteredInstance(inst1)).toBe(false); + expect(ReactDebugInstanceMap.isRegisteredInstance(inst2)).toBe(false); + + ReactDebugInstanceMap.registerInstance(inst1); + expect(ReactDebugInstanceMap.isRegisteredInstance(inst1)).toBe(true); + expect(ReactDebugInstanceMap.isRegisteredInstance(inst2)).toBe(false); + + ReactDebugInstanceMap.registerInstance(inst2); + expect(ReactDebugInstanceMap.isRegisteredInstance(inst1)).toBe(true); + expect(ReactDebugInstanceMap.isRegisteredInstance(inst2)).toBe(true); + + ReactDebugInstanceMap.unregisterInstance(inst2); + expect(ReactDebugInstanceMap.isRegisteredInstance(inst1)).toBe(true); + expect(ReactDebugInstanceMap.isRegisteredInstance(inst2)).toBe(false); + + ReactDebugInstanceMap.unregisterInstance(inst1); + expect(ReactDebugInstanceMap.isRegisteredInstance(inst1)).toBe(false); + expect(ReactDebugInstanceMap.isRegisteredInstance(inst2)).toBe(false); + }); + + it('should assign stable IDs', function() { + var inst1 = createStubInstance(); + var inst2 = createStubInstance(); + + var inst1ID = ReactDebugInstanceMap.getIDForInstance(inst1); + var inst2ID = ReactDebugInstanceMap.getIDForInstance(inst2); + expect(typeof inst1ID).toBe('string'); + expect(typeof inst2ID).toBe('string'); + expect(inst1ID).not.toBe(inst2ID); + + ReactDebugInstanceMap.registerInstance(inst1); + ReactDebugInstanceMap.registerInstance(inst2); + expect(ReactDebugInstanceMap.getIDForInstance(inst1)).toBe(inst1ID); + expect(ReactDebugInstanceMap.getIDForInstance(inst2)).toBe(inst2ID); + + ReactDebugInstanceMap.unregisterInstance(inst1); + ReactDebugInstanceMap.unregisterInstance(inst2); + expect(ReactDebugInstanceMap.getIDForInstance(inst1)).toBe(inst1ID); + expect(ReactDebugInstanceMap.getIDForInstance(inst2)).toBe(inst2ID); + }); + + it('should retrieve registered instance by its ID', function() { + var inst1 = createStubInstance(); + var inst2 = createStubInstance(); + + var inst1ID = ReactDebugInstanceMap.getIDForInstance(inst1); + var inst2ID = ReactDebugInstanceMap.getIDForInstance(inst2); + expect(ReactDebugInstanceMap.getInstanceByID(inst1ID)).toBe(null); + expect(ReactDebugInstanceMap.getInstanceByID(inst2ID)).toBe(null); + + ReactDebugInstanceMap.registerInstance(inst1); + ReactDebugInstanceMap.registerInstance(inst2); + expect(ReactDebugInstanceMap.getInstanceByID(inst1ID)).toBe(inst1); + expect(ReactDebugInstanceMap.getInstanceByID(inst2ID)).toBe(inst2); + + ReactDebugInstanceMap.unregisterInstance(inst1); + ReactDebugInstanceMap.unregisterInstance(inst2); + expect(ReactDebugInstanceMap.getInstanceByID(inst1ID)).toBe(null); + expect(ReactDebugInstanceMap.getInstanceByID(inst2ID)).toBe(null); + }); + + it('should warn when registering an instance twice', function() { + spyOn(console, 'error'); + + var inst = createStubInstance(); + ReactDebugInstanceMap.registerInstance(inst); + expect(console.error.argsForCall.length).toBe(0); + + ReactDebugInstanceMap.registerInstance(inst); + expect(console.error.argsForCall.length).toBe(1); + expect(console.error.argsForCall[0][0]).toContain( + 'There is an internal error in the React developer tools integration. ' + + 'A registered instance should not be registered again. ' + + 'Please report this as a bug in React.' + ); + + ReactDebugInstanceMap.unregisterInstance(inst); + ReactDebugInstanceMap.registerInstance(inst); + expect(console.error.argsForCall.length).toBe(1); + }); + + it('should warn when unregistering an instance twice', function() { + spyOn(console, 'error'); + var inst = createStubInstance(); + + ReactDebugInstanceMap.unregisterInstance(inst); + expect(console.error.argsForCall.length).toBe(1); + expect(console.error.argsForCall[0][0]).toContain( + 'There is an internal error in the React developer tools integration. ' + + 'An unregistered instance should not be unregistered again. ' + + 'Please report this as a bug in React.' + ); + + ReactDebugInstanceMap.registerInstance(inst); + ReactDebugInstanceMap.unregisterInstance(inst); + expect(console.error.argsForCall.length).toBe(1); + + ReactDebugInstanceMap.unregisterInstance(inst); + expect(console.error.argsForCall.length).toBe(2); + expect(console.error.argsForCall[1][0]).toContain( + 'There is an internal error in the React developer tools integration. ' + + 'An unregistered instance should not be unregistered again. ' + + 'Please report this as a bug in React.' + ); + }); + + it('should warn about anything than is not an internal instance', function() { + class Foo extends React.Component { + render() { + return
; + } + } + + spyOn(console, 'error'); + var warningCount = 0; + var div = document.createElement('div'); + var publicInst = ReactDOM.render(, div); + + [false, null, undefined, {}, div, publicInst].forEach(falsyValue => { + ReactDebugInstanceMap.registerInstance(falsyValue); + warningCount++; + expect(ReactDebugInstanceMap.getIDForInstance(falsyValue)).toBe(null); + warningCount++; + expect(ReactDebugInstanceMap.isRegisteredInstance(falsyValue)).toBe(false); + warningCount++; + ReactDebugInstanceMap.unregisterInstance(falsyValue); + warningCount++; + }); + + expect(console.error.argsForCall.length).toBe(warningCount); + for (var i = 0; i < warningCount.length; i++) { + // Ideally we could check for the more detailed error message here + // but it depends on the input type and is meant for internal bugs + // anyway so I don't think it's worth complicating the test with it. + expect(console.error.argsForCall[i][0]).toContain( + 'There is an internal error in the React developer tools integration.' + ); + } + }); +});