UNPKG

13.5 kBJavaScriptView Raw
1/* global __REACT_DEVTOOLS_GLOBAL_HOOK__ */
2
3import { options, Component } from 'preact';
4
5// Internal helpers from preact
6import { ATTR_KEY } from '../src/constants';
7import { isFunctionalComponent } from '../src/vdom/functional-component';
8
9/**
10 * Return a ReactElement-compatible object for the current state of a preact
11 * component.
12 */
13function createReactElement(component) {
14 return {
15 type: component.constructor,
16 key: component.key,
17 ref: null, // Unsupported
18 props: component.props
19 };
20}
21
22/**
23 * Create a ReactDOMComponent-compatible object for a given DOM node rendered
24 * by preact.
25 *
26 * This implements the subset of the ReactDOMComponent interface that
27 * React DevTools requires in order to display DOM nodes in the inspector with
28 * the correct type and properties.
29 *
30 * @param {Node} node
31 */
32function createReactDOMComponent(node) {
33 const childNodes = node.nodeType === Node.ELEMENT_NODE ?
34 Array.from(node.childNodes) : [];
35
36 const isText = node.nodeType === Node.TEXT_NODE;
37
38 return {
39 // --- ReactDOMComponent interface
40 _currentElement: isText ? node.textContent : {
41 type: node.nodeName.toLowerCase(),
42 props: node[ATTR_KEY]
43 },
44 _renderedChildren: childNodes.map(child => {
45 if (child._component) {
46 return updateReactComponent(child._component);
47 }
48 return updateReactComponent(child);
49 }),
50 _stringText: isText ? node.textContent : null,
51
52 // --- Additional properties used by preact devtools
53
54 // A flag indicating whether the devtools have been notified about the
55 // existence of this component instance yet.
56 // This is used to send the appropriate notifications when DOM components
57 // are added or updated between composite component updates.
58 _inDevTools: false,
59 node
60 };
61}
62
63/**
64 * Return the name of a component created by a `ReactElement`-like object.
65 *
66 * @param {ReactElement} element
67 */
68function typeName(element) {
69 if (typeof element.type === 'function') {
70 return element.type.displayName || element.type.name;
71 }
72 return element.type;
73}
74
75/**
76 * Return a ReactCompositeComponent-compatible object for a given preact
77 * component instance.
78 *
79 * This implements the subset of the ReactCompositeComponent interface that
80 * the DevTools requires in order to walk the component tree and inspect the
81 * component's properties.
82 *
83 * See https://github.com/facebook/react-devtools/blob/e31ec5825342eda570acfc9bcb43a44258fceb28/backend/getData.js
84 */
85function createReactCompositeComponent(component) {
86 const _currentElement = createReactElement(component);
87 const node = component.base;
88
89 let instance = {
90 // --- ReactDOMComponent properties
91 getName() {
92 return typeName(_currentElement);
93 },
94 _currentElement: createReactElement(component),
95 props: component.props,
96 state: component.state,
97 forceUpdate: component.forceUpdate.bind(component),
98 setState: component.setState.bind(component),
99
100 // --- Additional properties used by preact devtools
101 node
102 };
103
104 // React DevTools exposes the `_instance` field of the selected item in the
105 // component tree as `$r` in the console. `_instance` must refer to a
106 // React Component (or compatible) class instance with `props` and `state`
107 // fields and `setState()`, `forceUpdate()` methods.
108 instance._instance = component;
109
110 // If the root node returned by this component instance's render function
111 // was itself a composite component, there will be a `_component` property
112 // containing the child component instance.
113 if (component._component) {
114 instance._renderedComponent = updateReactComponent(component._component);
115 } else {
116 // Otherwise, if the render() function returned an HTML/SVG element,
117 // create a ReactDOMComponent-like object for the DOM node itself.
118 instance._renderedComponent = updateReactComponent(node);
119 }
120
121 return instance;
122}
123
124/**
125 * Map of Component|Node to ReactDOMComponent|ReactCompositeComponent-like
126 * object.
127 *
128 * The same React*Component instance must be used when notifying devtools
129 * about the initial mount of a component and subsequent updates.
130 */
131let instanceMap = new Map();
132
133/**
134 * Update (and create if necessary) the ReactDOMComponent|ReactCompositeComponent-like
135 * instance for a given preact component instance or DOM Node.
136 *
137 * @param {Component|Node} componentOrNode
138 */
139function updateReactComponent(componentOrNode) {
140 const newInstance = componentOrNode instanceof Node ?
141 createReactDOMComponent(componentOrNode) :
142 createReactCompositeComponent(componentOrNode);
143 if (instanceMap.has(componentOrNode)) {
144 let inst = instanceMap.get(componentOrNode);
145 Object.assign(inst, newInstance);
146 return inst;
147 }
148 instanceMap.set(componentOrNode, newInstance);
149 return newInstance;
150}
151
152function nextRootKey(roots) {
153 return '.' + Object.keys(roots).length;
154}
155
156/**
157 * Find all root component instances rendered by preact in `node`'s children
158 * and add them to the `roots` map.
159 *
160 * @param {DOMElement} node
161 * @param {[key: string] => ReactDOMComponent|ReactCompositeComponent}
162 */
163function findRoots(node, roots) {
164 Array.from(node.childNodes).forEach(child => {
165 if (child._component) {
166 roots[nextRootKey(roots)] = updateReactComponent(child._component);
167 } else {
168 findRoots(child, roots);
169 }
170 });
171}
172
173/**
174 * Map of functional component name -> wrapper class.
175 */
176let functionalComponentWrappers = new Map();
177
178/**
179 * Wrap a functional component with a stateful component.
180 *
181 * preact does not record any information about the original hierarchy of
182 * functional components in the rendered DOM nodes. Wrapping functional components
183 * with a trivial wrapper allows us to recover information about the original
184 * component structure from the DOM.
185 *
186 * @param {VNode} vnode
187 */
188function wrapFunctionalComponent(vnode) {
189 const originalRender = vnode.nodeName;
190 const name = vnode.nodeName.name || '(Function.name missing)';
191 const wrappers = functionalComponentWrappers;
192 if (!wrappers.has(originalRender)) {
193 let wrapper = class extends Component {
194 render(props, state, context) {
195 return originalRender(props, context);
196 }
197 };
198
199 // Expose the original component name. React Dev Tools will use
200 // this property if it exists or fall back to Function.name
201 // otherwise.
202 wrapper.displayName = name;
203
204 wrappers.set(originalRender, wrapper);
205 }
206 vnode.nodeName = wrappers.get(originalRender);
207}
208
209/**
210 * Create a bridge for exposing preact's component tree to React DevTools.
211 *
212 * It creates implementations of the interfaces that ReactDOM passes to
213 * devtools to enable it to query the component tree and hook into component
214 * updates.
215 *
216 * See https://github.com/facebook/react/blob/59ff7749eda0cd858d5ee568315bcba1be75a1ca/src/renderers/dom/ReactDOM.js
217 * for how ReactDOM exports its internals for use by the devtools and
218 * the `attachRenderer()` function in
219 * https://github.com/facebook/react-devtools/blob/e31ec5825342eda570acfc9bcb43a44258fceb28/backend/attachRenderer.js
220 * for how the devtools consumes the resulting objects.
221 */
222function createDevToolsBridge() {
223 // The devtools has different paths for interacting with the renderers from
224 // React Native, legacy React DOM and current React DOM.
225 //
226 // Here we emulate the interface for the current React DOM (v15+) lib.
227
228 // ReactDOMComponentTree-like object
229 const ComponentTree = {
230 getNodeFromInstance(instance) {
231 return instance.node;
232 },
233 getClosestInstanceFromNode(node) {
234 while (node && !node._component) {
235 node = node.parentNode;
236 }
237 return node ? updateReactComponent(node._component) : null;
238 }
239 };
240
241 // Map of root ID (the ID is unimportant) to component instance.
242 let roots = {};
243 findRoots(document.body, roots);
244
245 // ReactMount-like object
246 //
247 // Used by devtools to discover the list of root component instances and get
248 // notified when new root components are rendered.
249 const Mount = {
250 _instancesByReactRootID: roots,
251
252 // Stub - React DevTools expects to find this method and replace it
253 // with a wrapper in order to observe new root components being added
254 _renderNewRootComponent(/* instance, ... */) { }
255 };
256
257 // ReactReconciler-like object
258 const Reconciler = {
259 // Stubs - React DevTools expects to find these methods and replace them
260 // with wrappers in order to observe components being mounted, updated and
261 // unmounted
262 mountComponent(/* instance, ... */) { },
263 performUpdateIfNecessary(/* instance, ... */) { },
264 receiveComponent(/* instance, ... */) { },
265 unmountComponent(/* instance, ... */) { }
266 };
267
268 /** Notify devtools that a new component instance has been mounted into the DOM. */
269 const componentAdded = component => {
270 const instance = updateReactComponent(component);
271 if (isRootComponent(component)) {
272 instance._rootID = nextRootKey(roots);
273 roots[instance._rootID] = instance;
274 Mount._renderNewRootComponent(instance);
275 }
276 visitNonCompositeChildren(instance, childInst => {
277 childInst._inDevTools = true;
278 Reconciler.mountComponent(childInst);
279 });
280 Reconciler.mountComponent(instance);
281 };
282
283 /** Notify devtools that a component has been updated with new props/state. */
284 const componentUpdated = component => {
285 const prevRenderedChildren = [];
286 visitNonCompositeChildren(instanceMap.get(component), childInst => {
287 prevRenderedChildren.push(childInst);
288 });
289
290 // Notify devtools about updates to this component and any non-composite
291 // children
292 const instance = updateReactComponent(component);
293 Reconciler.receiveComponent(instance);
294 visitNonCompositeChildren(instance, childInst => {
295 if (!childInst._inDevTools) {
296 // New DOM child component
297 childInst._inDevTools = true;
298 Reconciler.mountComponent(childInst);
299 } else {
300 // Updated DOM child component
301 Reconciler.receiveComponent(childInst);
302 }
303 });
304
305 // For any non-composite children that were removed by the latest render,
306 // remove the corresponding ReactDOMComponent-like instances and notify
307 // the devtools
308 prevRenderedChildren.forEach(childInst => {
309 if (!document.body.contains(childInst.node)) {
310 instanceMap.delete(childInst.node);
311 Reconciler.unmountComponent(childInst);
312 }
313 });
314 };
315
316 /** Notify devtools that a component has been unmounted from the DOM. */
317 const componentRemoved = component => {
318 const instance = updateReactComponent(component);
319 visitNonCompositeChildren(childInst => {
320 instanceMap.delete(childInst.node);
321 Reconciler.unmountComponent(childInst);
322 });
323 Reconciler.unmountComponent(instance);
324 instanceMap.delete(component);
325 if (instance._rootID) {
326 delete roots[instance._rootID];
327 }
328 };
329
330 return {
331 componentAdded,
332 componentUpdated,
333 componentRemoved,
334
335 // Interfaces passed to devtools via __REACT_DEVTOOLS_GLOBAL_HOOK__.inject()
336 ComponentTree,
337 Mount,
338 Reconciler
339 };
340}
341
342/**
343 * Return `true` if a preact component is a top level component rendered by
344 * `render()` into a container Element.
345 */
346function isRootComponent(component) {
347 return !component.base.parentElement || !component.base.parentElement[ATTR_KEY];
348}
349
350/**
351 * Visit all child instances of a ReactCompositeComponent-like object that are
352 * not composite components (ie. they represent DOM elements or text)
353 *
354 * @param {Component} component
355 * @param {(Component) => void} visitor
356 */
357function visitNonCompositeChildren(component, visitor) {
358 if (component._renderedComponent) {
359 if (!component._renderedComponent._component) {
360 visitor(component._renderedComponent);
361 visitNonCompositeChildren(component._renderedComponent, visitor);
362 }
363 } else if (component._renderedChildren) {
364 component._renderedChildren.forEach(child => {
365 visitor(child);
366 if (!child._component) visitNonCompositeChildren(child, visitor);
367 });
368 }
369}
370
371/**
372 * Create a bridge between the preact component tree and React's dev tools
373 * and register it.
374 *
375 * After this function is called, the React Dev Tools should be able to detect
376 * "React" on the page and show the component tree.
377 *
378 * This function hooks into preact VNode creation in order to expose functional
379 * components correctly, so it should be called before the root component(s)
380 * are rendered.
381 *
382 * Returns a cleanup function which unregisters the hooks.
383 */
384export function initDevTools() {
385 if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ === 'undefined') {
386 // React DevTools are not installed
387 return;
388 }
389
390 // Hook into preact element creation in order to wrap functional components
391 // with stateful ones in order to make them visible in the devtools
392 const nextVNode = options.vnode;
393 options.vnode = (vnode) => {
394 if (isFunctionalComponent(vnode)) wrapFunctionalComponent(vnode);
395 if (nextVNode) return nextVNode(vnode);
396 };
397
398 // Notify devtools when preact components are mounted, updated or unmounted
399 const bridge = createDevToolsBridge();
400
401 const nextAfterMount = options.afterMount;
402 options.afterMount = component => {
403 bridge.componentAdded(component);
404 if (nextAfterMount) nextAfterMount(component);
405 };
406
407 const nextAfterUpdate = options.afterUpdate;
408 options.afterUpdate = component => {
409 bridge.componentUpdated(component);
410 if (nextAfterUpdate) nextAfterUpdate(component);
411 };
412
413 const nextBeforeUnmount = options.beforeUnmount;
414 options.beforeUnmount = component => {
415 bridge.componentRemoved(component);
416 if (nextBeforeUnmount) nextBeforeUnmount(component);
417 };
418
419 // Notify devtools about this instance of "React"
420 __REACT_DEVTOOLS_GLOBAL_HOOK__.inject(bridge);
421
422 return () => {
423 options.afterMount = nextAfterMount;
424 options.afterUpdate = nextAfterUpdate;
425 options.beforeUnmount = nextBeforeUnmount;
426 };
427}