428 lines
13 KiB
JavaScript
428 lines
13 KiB
JavaScript
|
/* global __REACT_DEVTOOLS_GLOBAL_HOOK__ */
|
||
|
|
||
|
import { options, Component } from 'preact';
|
||
|
|
||
|
// Internal helpers from preact
|
||
|
import { ATTR_KEY } from '../src/constants';
|
||
|
import { isFunctionalComponent } from '../src/vdom/functional-component';
|
||
|
|
||
|
/**
|
||
|
* Return a ReactElement-compatible object for the current state of a preact
|
||
|
* component.
|
||
|
*/
|
||
|
function createReactElement(component) {
|
||
|
return {
|
||
|
type: component.constructor,
|
||
|
key: component.key,
|
||
|
ref: null, // Unsupported
|
||
|
props: component.props
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Create a ReactDOMComponent-compatible object for a given DOM node rendered
|
||
|
* by preact.
|
||
|
*
|
||
|
* This implements the subset of the ReactDOMComponent interface that
|
||
|
* React DevTools requires in order to display DOM nodes in the inspector with
|
||
|
* the correct type and properties.
|
||
|
*
|
||
|
* @param {Node} node
|
||
|
*/
|
||
|
function createReactDOMComponent(node) {
|
||
|
const childNodes = node.nodeType === Node.ELEMENT_NODE ?
|
||
|
Array.from(node.childNodes) : [];
|
||
|
|
||
|
const isText = node.nodeType === Node.TEXT_NODE;
|
||
|
|
||
|
return {
|
||
|
// --- ReactDOMComponent interface
|
||
|
_currentElement: isText ? node.textContent : {
|
||
|
type: node.nodeName.toLowerCase(),
|
||
|
props: node[ATTR_KEY]
|
||
|
},
|
||
|
_renderedChildren: childNodes.map(child => {
|
||
|
if (child._component) {
|
||
|
return updateReactComponent(child._component);
|
||
|
}
|
||
|
return updateReactComponent(child);
|
||
|
}),
|
||
|
_stringText: isText ? node.textContent : null,
|
||
|
|
||
|
// --- Additional properties used by preact devtools
|
||
|
|
||
|
// A flag indicating whether the devtools have been notified about the
|
||
|
// existence of this component instance yet.
|
||
|
// This is used to send the appropriate notifications when DOM components
|
||
|
// are added or updated between composite component updates.
|
||
|
_inDevTools: false,
|
||
|
node
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Return the name of a component created by a `ReactElement`-like object.
|
||
|
*
|
||
|
* @param {ReactElement} element
|
||
|
*/
|
||
|
function typeName(element) {
|
||
|
if (typeof element.type === 'function') {
|
||
|
return element.type.displayName || element.type.name;
|
||
|
}
|
||
|
return element.type;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Return a ReactCompositeComponent-compatible object for a given preact
|
||
|
* component instance.
|
||
|
*
|
||
|
* This implements the subset of the ReactCompositeComponent interface that
|
||
|
* the DevTools requires in order to walk the component tree and inspect the
|
||
|
* component's properties.
|
||
|
*
|
||
|
* See https://github.com/facebook/react-devtools/blob/e31ec5825342eda570acfc9bcb43a44258fceb28/backend/getData.js
|
||
|
*/
|
||
|
function createReactCompositeComponent(component) {
|
||
|
const _currentElement = createReactElement(component);
|
||
|
const node = component.base;
|
||
|
|
||
|
let instance = {
|
||
|
// --- ReactDOMComponent properties
|
||
|
getName() {
|
||
|
return typeName(_currentElement);
|
||
|
},
|
||
|
_currentElement: createReactElement(component),
|
||
|
props: component.props,
|
||
|
state: component.state,
|
||
|
forceUpdate: component.forceUpdate.bind(component),
|
||
|
setState: component.setState.bind(component),
|
||
|
|
||
|
// --- Additional properties used by preact devtools
|
||
|
node
|
||
|
};
|
||
|
|
||
|
// React DevTools exposes the `_instance` field of the selected item in the
|
||
|
// component tree as `$r` in the console. `_instance` must refer to a
|
||
|
// React Component (or compatible) class instance with `props` and `state`
|
||
|
// fields and `setState()`, `forceUpdate()` methods.
|
||
|
instance._instance = component;
|
||
|
|
||
|
// If the root node returned by this component instance's render function
|
||
|
// was itself a composite component, there will be a `_component` property
|
||
|
// containing the child component instance.
|
||
|
if (component._component) {
|
||
|
instance._renderedComponent = updateReactComponent(component._component);
|
||
|
} else {
|
||
|
// Otherwise, if the render() function returned an HTML/SVG element,
|
||
|
// create a ReactDOMComponent-like object for the DOM node itself.
|
||
|
instance._renderedComponent = updateReactComponent(node);
|
||
|
}
|
||
|
|
||
|
return instance;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Map of Component|Node to ReactDOMComponent|ReactCompositeComponent-like
|
||
|
* object.
|
||
|
*
|
||
|
* The same React*Component instance must be used when notifying devtools
|
||
|
* about the initial mount of a component and subsequent updates.
|
||
|
*/
|
||
|
let instanceMap = new Map();
|
||
|
|
||
|
/**
|
||
|
* Update (and create if necessary) the ReactDOMComponent|ReactCompositeComponent-like
|
||
|
* instance for a given preact component instance or DOM Node.
|
||
|
*
|
||
|
* @param {Component|Node} componentOrNode
|
||
|
*/
|
||
|
function updateReactComponent(componentOrNode) {
|
||
|
const newInstance = componentOrNode instanceof Node ?
|
||
|
createReactDOMComponent(componentOrNode) :
|
||
|
createReactCompositeComponent(componentOrNode);
|
||
|
if (instanceMap.has(componentOrNode)) {
|
||
|
let inst = instanceMap.get(componentOrNode);
|
||
|
Object.assign(inst, newInstance);
|
||
|
return inst;
|
||
|
}
|
||
|
instanceMap.set(componentOrNode, newInstance);
|
||
|
return newInstance;
|
||
|
}
|
||
|
|
||
|
function nextRootKey(roots) {
|
||
|
return '.' + Object.keys(roots).length;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Find all root component instances rendered by preact in `node`'s children
|
||
|
* and add them to the `roots` map.
|
||
|
*
|
||
|
* @param {DOMElement} node
|
||
|
* @param {[key: string] => ReactDOMComponent|ReactCompositeComponent}
|
||
|
*/
|
||
|
function findRoots(node, roots) {
|
||
|
Array.from(node.childNodes).forEach(child => {
|
||
|
if (child._component) {
|
||
|
roots[nextRootKey(roots)] = updateReactComponent(child._component);
|
||
|
} else {
|
||
|
findRoots(child, roots);
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Map of functional component name -> wrapper class.
|
||
|
*/
|
||
|
let functionalComponentWrappers = new Map();
|
||
|
|
||
|
/**
|
||
|
* Wrap a functional component with a stateful component.
|
||
|
*
|
||
|
* preact does not record any information about the original hierarchy of
|
||
|
* functional components in the rendered DOM nodes. Wrapping functional components
|
||
|
* with a trivial wrapper allows us to recover information about the original
|
||
|
* component structure from the DOM.
|
||
|
*
|
||
|
* @param {VNode} vnode
|
||
|
*/
|
||
|
function wrapFunctionalComponent(vnode) {
|
||
|
const originalRender = vnode.nodeName;
|
||
|
const name = vnode.nodeName.name || '(Function.name missing)';
|
||
|
const wrappers = functionalComponentWrappers;
|
||
|
if (!wrappers.has(originalRender)) {
|
||
|
let wrapper = class extends Component {
|
||
|
render(props, state, context) {
|
||
|
return originalRender(props, context);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// Expose the original component name. React Dev Tools will use
|
||
|
// this property if it exists or fall back to Function.name
|
||
|
// otherwise.
|
||
|
wrapper.displayName = name;
|
||
|
|
||
|
wrappers.set(originalRender, wrapper);
|
||
|
}
|
||
|
vnode.nodeName = wrappers.get(originalRender);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Create a bridge for exposing preact's component tree to React DevTools.
|
||
|
*
|
||
|
* It creates implementations of the interfaces that ReactDOM passes to
|
||
|
* devtools to enable it to query the component tree and hook into component
|
||
|
* updates.
|
||
|
*
|
||
|
* See https://github.com/facebook/react/blob/59ff7749eda0cd858d5ee568315bcba1be75a1ca/src/renderers/dom/ReactDOM.js
|
||
|
* for how ReactDOM exports its internals for use by the devtools and
|
||
|
* the `attachRenderer()` function in
|
||
|
* https://github.com/facebook/react-devtools/blob/e31ec5825342eda570acfc9bcb43a44258fceb28/backend/attachRenderer.js
|
||
|
* for how the devtools consumes the resulting objects.
|
||
|
*/
|
||
|
function createDevToolsBridge() {
|
||
|
// The devtools has different paths for interacting with the renderers from
|
||
|
// React Native, legacy React DOM and current React DOM.
|
||
|
//
|
||
|
// Here we emulate the interface for the current React DOM (v15+) lib.
|
||
|
|
||
|
// ReactDOMComponentTree-like object
|
||
|
const ComponentTree = {
|
||
|
getNodeFromInstance(instance) {
|
||
|
return instance.node;
|
||
|
},
|
||
|
getClosestInstanceFromNode(node) {
|
||
|
while (node && !node._component) {
|
||
|
node = node.parentNode;
|
||
|
}
|
||
|
return node ? updateReactComponent(node._component) : null;
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// Map of root ID (the ID is unimportant) to component instance.
|
||
|
let roots = {};
|
||
|
findRoots(document.body, roots);
|
||
|
|
||
|
// ReactMount-like object
|
||
|
//
|
||
|
// Used by devtools to discover the list of root component instances and get
|
||
|
// notified when new root components are rendered.
|
||
|
const Mount = {
|
||
|
_instancesByReactRootID: roots,
|
||
|
|
||
|
// Stub - React DevTools expects to find this method and replace it
|
||
|
// with a wrapper in order to observe new root components being added
|
||
|
_renderNewRootComponent(/* instance, ... */) { }
|
||
|
};
|
||
|
|
||
|
// ReactReconciler-like object
|
||
|
const Reconciler = {
|
||
|
// Stubs - React DevTools expects to find these methods and replace them
|
||
|
// with wrappers in order to observe components being mounted, updated and
|
||
|
// unmounted
|
||
|
mountComponent(/* instance, ... */) { },
|
||
|
performUpdateIfNecessary(/* instance, ... */) { },
|
||
|
receiveComponent(/* instance, ... */) { },
|
||
|
unmountComponent(/* instance, ... */) { }
|
||
|
};
|
||
|
|
||
|
/** Notify devtools that a new component instance has been mounted into the DOM. */
|
||
|
const componentAdded = component => {
|
||
|
const instance = updateReactComponent(component);
|
||
|
if (isRootComponent(component)) {
|
||
|
instance._rootID = nextRootKey(roots);
|
||
|
roots[instance._rootID] = instance;
|
||
|
Mount._renderNewRootComponent(instance);
|
||
|
}
|
||
|
visitNonCompositeChildren(instance, childInst => {
|
||
|
childInst._inDevTools = true;
|
||
|
Reconciler.mountComponent(childInst);
|
||
|
});
|
||
|
Reconciler.mountComponent(instance);
|
||
|
};
|
||
|
|
||
|
/** Notify devtools that a component has been updated with new props/state. */
|
||
|
const componentUpdated = component => {
|
||
|
const prevRenderedChildren = [];
|
||
|
visitNonCompositeChildren(instanceMap.get(component), childInst => {
|
||
|
prevRenderedChildren.push(childInst);
|
||
|
});
|
||
|
|
||
|
// Notify devtools about updates to this component and any non-composite
|
||
|
// children
|
||
|
const instance = updateReactComponent(component);
|
||
|
Reconciler.receiveComponent(instance);
|
||
|
visitNonCompositeChildren(instance, childInst => {
|
||
|
if (!childInst._inDevTools) {
|
||
|
// New DOM child component
|
||
|
childInst._inDevTools = true;
|
||
|
Reconciler.mountComponent(childInst);
|
||
|
} else {
|
||
|
// Updated DOM child component
|
||
|
Reconciler.receiveComponent(childInst);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// For any non-composite children that were removed by the latest render,
|
||
|
// remove the corresponding ReactDOMComponent-like instances and notify
|
||
|
// the devtools
|
||
|
prevRenderedChildren.forEach(childInst => {
|
||
|
if (!document.body.contains(childInst.node)) {
|
||
|
instanceMap.delete(childInst.node);
|
||
|
Reconciler.unmountComponent(childInst);
|
||
|
}
|
||
|
});
|
||
|
};
|
||
|
|
||
|
/** Notify devtools that a component has been unmounted from the DOM. */
|
||
|
const componentRemoved = component => {
|
||
|
const instance = updateReactComponent(component);
|
||
|
visitNonCompositeChildren(childInst => {
|
||
|
instanceMap.delete(childInst.node);
|
||
|
Reconciler.unmountComponent(childInst);
|
||
|
});
|
||
|
Reconciler.unmountComponent(instance);
|
||
|
instanceMap.delete(component);
|
||
|
if (instance._rootID) {
|
||
|
delete roots[instance._rootID];
|
||
|
}
|
||
|
};
|
||
|
|
||
|
return {
|
||
|
componentAdded,
|
||
|
componentUpdated,
|
||
|
componentRemoved,
|
||
|
|
||
|
// Interfaces passed to devtools via __REACT_DEVTOOLS_GLOBAL_HOOK__.inject()
|
||
|
ComponentTree,
|
||
|
Mount,
|
||
|
Reconciler
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Return `true` if a preact component is a top level component rendered by
|
||
|
* `render()` into a container Element.
|
||
|
*/
|
||
|
function isRootComponent(component) {
|
||
|
return !component.base.parentElement || !component.base.parentElement[ATTR_KEY];
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Visit all child instances of a ReactCompositeComponent-like object that are
|
||
|
* not composite components (ie. they represent DOM elements or text)
|
||
|
*
|
||
|
* @param {Component} component
|
||
|
* @param {(Component) => void} visitor
|
||
|
*/
|
||
|
function visitNonCompositeChildren(component, visitor) {
|
||
|
if (component._renderedComponent) {
|
||
|
if (!component._renderedComponent._component) {
|
||
|
visitor(component._renderedComponent);
|
||
|
visitNonCompositeChildren(component._renderedComponent, visitor);
|
||
|
}
|
||
|
} else if (component._renderedChildren) {
|
||
|
component._renderedChildren.forEach(child => {
|
||
|
visitor(child);
|
||
|
if (!child._component) visitNonCompositeChildren(child, visitor);
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Create a bridge between the preact component tree and React's dev tools
|
||
|
* and register it.
|
||
|
*
|
||
|
* After this function is called, the React Dev Tools should be able to detect
|
||
|
* "React" on the page and show the component tree.
|
||
|
*
|
||
|
* This function hooks into preact VNode creation in order to expose functional
|
||
|
* components correctly, so it should be called before the root component(s)
|
||
|
* are rendered.
|
||
|
*
|
||
|
* Returns a cleanup function which unregisters the hooks.
|
||
|
*/
|
||
|
export function initDevTools() {
|
||
|
if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ === 'undefined') {
|
||
|
// React DevTools are not installed
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Hook into preact element creation in order to wrap functional components
|
||
|
// with stateful ones in order to make them visible in the devtools
|
||
|
const nextVNode = options.vnode;
|
||
|
options.vnode = (vnode) => {
|
||
|
if (isFunctionalComponent(vnode)) wrapFunctionalComponent(vnode);
|
||
|
if (nextVNode) return nextVNode(vnode);
|
||
|
};
|
||
|
|
||
|
// Notify devtools when preact components are mounted, updated or unmounted
|
||
|
const bridge = createDevToolsBridge();
|
||
|
|
||
|
const nextAfterMount = options.afterMount;
|
||
|
options.afterMount = component => {
|
||
|
bridge.componentAdded(component);
|
||
|
if (nextAfterMount) nextAfterMount(component);
|
||
|
};
|
||
|
|
||
|
const nextAfterUpdate = options.afterUpdate;
|
||
|
options.afterUpdate = component => {
|
||
|
bridge.componentUpdated(component);
|
||
|
if (nextAfterUpdate) nextAfterUpdate(component);
|
||
|
};
|
||
|
|
||
|
const nextBeforeUnmount = options.beforeUnmount;
|
||
|
options.beforeUnmount = component => {
|
||
|
bridge.componentRemoved(component);
|
||
|
if (nextBeforeUnmount) nextBeforeUnmount(component);
|
||
|
};
|
||
|
|
||
|
// Notify devtools about this instance of "React"
|
||
|
__REACT_DEVTOOLS_GLOBAL_HOOK__.inject(bridge);
|
||
|
|
||
|
return () => {
|
||
|
options.afterMount = nextAfterMount;
|
||
|
options.afterUpdate = nextAfterUpdate;
|
||
|
options.beforeUnmount = nextBeforeUnmount;
|
||
|
};
|
||
|
}
|