preact
Version:
Fast 3kb React-compatible Virtual DOM library.
589 lines (525 loc) • 15.7 kB
JavaScript
import { Fragment } from 'preact';
import { createIdMapper } from './IdMapper';
import { getStringId, flushTable } from './string-table';
import {
isRoot,
findRoot,
getAncestor,
isSuspenseVNode,
getDisplayName,
getComponentHooks,
getActualChildren,
isConsumerVNode
} from './vnode';
import { shouldFilter } from './filter';
import { cleanContext, jsonify, cleanProps, traverse, setIn } from './utils';
import {
MEMO,
FORWARD_REF,
SUSPENSE,
CLASS_COMPONENT,
FUNCTION_COMPONENT,
HTML_ELEMENT,
REMOVE_VNODE,
ADD_ROOT,
ADD_VNODE,
UPDATE_VNODE_TIMINGS,
REORDER_CHILDREN
} from './constants';
let memoReg = /^Memo\(/;
let forwardRefReg = /^ForwardRef\(/;
/**
* Get the type of a vnode. The devtools uses these constants to differentiate
* between the various forms of components.
*
* @param {import('../../internal').VNode} vnode
* @returns {number}
*/
export function getDevtoolsType(vnode) {
if (typeof vnode.type == 'function' && vnode.type !== Fragment) {
const name = getDisplayName(vnode);
if (memoReg.test(name)) return MEMO;
if (forwardRefReg.test(name)) return FORWARD_REF;
if (isSuspenseVNode(vnode)) return SUSPENSE;
// TODO: Provider and Consumer
return vnode.type.prototype && vnode.type.prototype.render
? CLASS_COMPONENT
: FUNCTION_COMPONENT;
}
return HTML_ELEMENT;
}
/**
* Check if a variable is a `vnode`
* @param {*} x
* @returns {boolean}
*/
export function isVNode(x) {
return x != null && x.type !== undefined && x._dom !== undefined;
}
/**
* Serialize a vnode
* @param {*} x
* @returns {import('./types').SerializedVNode |null}
*/
export function serializeVNode(x) {
if (isVNode(x)) {
return {
type: 'vnode',
name: getDisplayName(x)
};
}
return null;
}
/**
* Collect all relevant data from a commit and convert it to a message
* the detools can understand
* @param {import('./types').Commit} commit
*/
export function flush(commit) {
const { rootId, unmountIds, operations, strings } = commit;
let msg = [rootId, ...flushTable(strings)];
if (unmountIds.length > 0) {
msg.push(REMOVE_VNODE, unmountIds.length, ...unmountIds);
}
msg.push(...operations);
return { name: 'operation', data: msg };
}
/** @type {import('./types').FilterState} */
let defaultFilters = {
regex: [],
type: new Set(['dom', 'fragment'])
};
/**
* The renderer is responsible for translating anything preact rendered
* into a serializable format that is passed to the devtools extension.
* On top of that the devtools can call the renderer to request certain
* information about `vnodes`. This is usually done lazily, so that we
* don't waste any precious CPU time.
*
* Instead of passing `json` objects around, we're converting everythign
* to a custom format that is representable using a number array. It's one
* of the major performance improvements the react team made in their devtools
* v4 code.
*
* The translation process always happens after a commit has finished.
* This has the advantage of not tainting measured timings for rendering
* `vnodes`. But it has the disadvantage that we need to reconstruct what
* changes were done in each commit. Nonetheless I do think the additional
* complexity is worth it, given the better and less confusing user experience.
*
* @param {import('./types').PreactDevtoolsHook} hook
* @param {import('./types').FilterState} filters
* @returns {import('./types').Renderer}
*/
export function createRenderer(hook, filters = defaultFilters) {
const ids = createIdMapper();
/** @type {Set<import('../../internal').VNode>} */
const roots = new Set();
/**
* Queue events until the extension is connected
* @type {import('./types').DevtoolsEvent[]}
*/
let queue = [];
/** @type {number[]} */
let currentUnmounts = [];
/** @type {WeakMap<HTMLElement | Text, import('../../internal').VNode>} */
let domToVNode = new WeakMap();
return {
getVNodeById: id => ids.getVNode(id),
has: id => ids.has(id),
getDisplayName,
forceUpdate: id => {
const vnode = ids.getVNode(id);
if (vnode) {
const c = vnode._component;
if (c) c.forceUpdate();
}
},
/**
* Print out a `vnode` to the native devtools console. Called when
* the bug icon is pressed in the devtools sidebar panel.
* @param {number} id
* @param {number[]} children
*/
/* istanbul ignore next */
log(id, children) {
const vnode = ids.getVNode(id);
if (vnode == null) {
console.warn(`Could not find vnode with id ${id}`);
return;
}
logVNode(vnode, id, children);
},
/**
* Retrieve all `vnode` details like `props`, `state` and `context`
* to be displayed in the sidebar. We only request this information
* when a `vnode` is selected in the devtools extension.
* @param {number} id
* @returns {import('./types').InspectData | null}
*/
inspect(id) {
const vnode = ids.getVNode(id);
if (!vnode) return null;
const c = vnode._component;
const hasState =
typeof vnode.type === 'function' &&
c != null &&
Object.keys(c.state).length > 0;
const hasHooks = c != null && getComponentHooks(c) != null;
let context = null;
if (c != null) {
context = isConsumerVNode(vnode)
? {
value: c.context
}
: cleanContext(c.context);
}
return {
context: context != null ? jsonify(context, serializeVNode) : null,
canEditHooks: hasHooks,
hooks: null,
id,
name: getDisplayName(vnode),
canEditProps: true,
props: jsonify(cleanProps(vnode.props), serializeVNode),
canEditState: true,
state: hasState ? jsonify(c.state, serializeVNode) : null,
type: getDevtoolsType(vnode)
};
},
/**
* Get the DOM nodes associated with a `vnode`. For `Fragments` this can be
* a range of nodes (experimental).
* @param {number} id
* @returns {[HTMLElement | Text | null, HTMLElement | Text | null] | null}
*/
findDomForVNode(id) {
const vnode = ids.getVNode(id);
return vnode ? [vnode._dom, vnode._lastDomChild] : null;
},
/**
* Get the `id` associated with a `vnode`.
* @param {HTMLElement | Text} node
* @returns {number | null}
*/
findVNodeIdForDom(node) {
const vnode = domToVNode.get(node);
if (vnode) {
if (shouldFilter(vnode, filters)) {
let p = vnode;
while ((p = p._parent) != null) {
/* istanbul ignore else */
if (!shouldFilter(p, filters)) break;
}
return ids.getId(p);
}
return ids.getId(vnode);
}
return -1;
},
/**
* Called when the user changes filtering in the extension.
* @param {import('./types').FilterState} nextFilters
*/
applyFilters(nextFilters) {
roots.forEach(root => {
const children = getActualChildren(root);
/* istanbul ignore else */
if (children.length > 0 && children[0] != null) {
traverse(/** @type{*} */ (children[0]), vnode =>
this.onUnmount(vnode)
);
}
/** @type {import('./types').Commit} */
const commit = {
operations: [],
rootId: ids.getId(root),
strings: new Map(),
unmountIds: currentUnmounts
};
const unmounts = flush(commit);
currentUnmounts = [];
queue.push(unmounts);
});
filters.regex = nextFilters.regex;
filters.type = nextFilters.type;
roots.forEach(root => {
const commit = createCommit(ids, roots, root, filters, domToVNode);
const ev = flush(commit);
queue.push(ev);
});
/* istanbul ignore else */
if (hook.connected) {
this.flushInitial();
}
},
/**
* Flush all events that may have been queued before the devtools are
* done initializing.
*/
flushInitial() {
queue.forEach(ev => hook.emit(ev.name, ev.data));
hook.connected = true;
queue = [];
},
/**
* Main entry function that's called whenever a commit completed. From here
* on we walk the view tree and store any changes in an operations array
* that the devtools can understand. If we're connected to the extension
* we message the events, and if not we'll queue them until the extension
* becomes active.
* @param {import('../../internal').VNode} vnode
*/
onCommit(vnode) {
const commit = createCommit(ids, roots, vnode, filters, domToVNode);
commit.unmountIds.push(...currentUnmounts);
currentUnmounts = [];
const ev = flush(commit);
/* istanbul ignore else */
if (hook.connected) {
hook.emit(ev.name, ev.data);
} else {
queue.push(ev);
}
},
/**
* Called when a `vnode` is removed.
* @param {import('../../internal').VNode} vnode
*/
onUnmount(vnode) {
if (!shouldFilter(vnode, filters)) {
/* istanbul ignore else */
if (ids.hasId(vnode)) {
currentUnmounts.push(ids.getId(vnode));
}
} else if (typeof vnode.type !== 'function') {
const dom = vnode._dom;
/* istanbul ignore next */
if (dom != null) domToVNode.delete(dom);
}
ids.remove(vnode);
},
/**
* Apply an update that was triggered in the extension. That's usually
* done via any of the input elements in the sidebar.
* @param {number} id
* @param {'props' | 'state' | 'context' | 'hooks'} type
* @param {Array<string, number>} path
* @param {any} value
*/
update(id, type, path, value) {
const vnode = ids.getVNode(id);
if (vnode !== null) {
if (typeof vnode.type === 'function') {
const c = vnode._component;
/* istanbul ignore else */
if (type === 'props') {
/* istanbul ignore next */
setIn(vnode.props || {}, path.slice(), value);
} else if (type === 'state') {
setIn(c.state || {}, path.slice(), value);
} else if (type === 'context') {
setIn(c.context || {}, path.slice(), value);
}
c.forceUpdate();
}
}
}
};
}
/**
* Print an element to console
* @param {import('../../internal').VNode} vnode
* @param {number} id
* @param {number[]} children
*/
/* istanbul ignore next */
export function logVNode(vnode, id, children) {
const display = getDisplayName(vnode);
const name = display === '#text' ? display : `<${display || 'Component'} />`;
/* eslint-disable no-console */
console.group(`LOG %c${name}`, 'color: #ea88fd; font-weight: normal');
console.log('props:', vnode.props);
const c = vnode._component;
if (c != null) {
console.log('state:', c.state);
}
console.log('vnode:', vnode);
console.log('devtools id:', id);
console.log('devtools children:', children);
console.groupEnd();
/* eslint-enable no-console */
}
/**
* Walk a `vnode` tree and compare it with the previous one. If any
* changes are detected they will be stored in the return value.
* @param {import('./types').IdMapper} ids
* @param {Set<import('../../internal').VNode>} roots
* @param {import('../../internal').VNode} vnode
* @param {import('./types').FilterState} filters
* @param {WeakMap<HTMLElement | Text, import('../../internal').VNode>} filters
* @returns {import('./types').Commit}
*/
export function createCommit(ids, roots, vnode, filters, domCache) {
const commit = {
operations: [],
rootId: -1,
strings: new Map(),
unmountIds: []
};
let parentId = -1;
const isNew = !ids.hasId(vnode);
if (isRoot(vnode)) {
const rootId = !isNew ? ids.getId(vnode) : ids.createId(vnode);
parentId = commit.rootId = rootId;
roots.add(vnode);
} else {
const root = findRoot(vnode);
commit.rootId = ids.getId(root);
parentId = ids.getId(getAncestor(vnode));
}
if (isNew) {
mount(ids, commit, vnode, parentId, filters, domCache);
} else {
update(ids, commit, vnode, parentId, filters, domCache);
}
return commit;
}
/**
* Mount a `vnode`
* @param {import('./types').IdMapper} ids
* @param {import('./types').Commit} commit
* @param {import('../../internal').VNode} vnode
* @param {number} ancestorId
* @param {import('./types').FilterState} filters
* @param {WeakMap<HTMLElement | Text, import('../../internal').VNode>} filters
*/
export function mount(ids, commit, vnode, ancestorId, filters, domCache) {
const root = isRoot(vnode);
const skip = shouldFilter(vnode, filters);
if (root || !skip) {
const id = ids.hasId(vnode) ? ids.getId(vnode) : ids.createId(vnode);
if (isRoot(vnode)) {
commit.operations.push(ADD_ROOT, id);
}
commit.operations.push(
ADD_VNODE,
id,
getDevtoolsType(vnode), // Type
ancestorId,
9999, // owner
getStringId(commit.strings, getDisplayName(vnode)),
vnode.key ? getStringId(commit.strings, vnode.key) : 0
);
ancestorId = id;
}
if (typeof vnode.type !== 'function') {
const dom = vnode._dom;
// TODO: Find a test case
/* istanbul ignore next */
if (dom) domCache.set(dom, vnode);
}
const children = getActualChildren(vnode);
for (let i = 0; i < children.length; i++) {
const child = /** @type {*} */ (children[i]);
if (child != null) {
mount(ids, commit, child, ancestorId, filters, domCache);
}
}
}
/**
* Mark parent vnode for recalculation of children
* @param {import('./types').IdMapper} ids
* @param {import('./types').Commit} commit
* @param {import('../../internal').VNode} vnode
* @param {number} ancestorId
* @param {import('./types').FilterState} filters
* @param {WeakMap<HTMLElement | Text, import('../../internal').VNode>} filters
*/
export function update(ids, commit, vnode, ancestorId, filters, domCache) {
const skip = shouldFilter(vnode, filters);
if (skip) {
const children = getActualChildren(vnode);
for (let i = 0; i < children.length; i++) {
const child = /** @type {*} */ (children[i]);
if (child != null) {
update(ids, commit, child, ancestorId, filters, domCache);
}
}
return;
}
if (!ids.hasId(vnode)) {
mount(ids, commit, vnode, ancestorId, filters, domCache);
return true;
}
const id = ids.getId(vnode);
commit.operations.push(
UPDATE_VNODE_TIMINGS,
id,
vnode.endTime - vnode.startTime
);
ids.update(id, vnode);
let shouldReorder = false;
const children = getActualChildren(vnode);
for (let i = 0; i < children.length; i++) {
const child = /** @type {*} */ (children[i]);
if (child == null) {
} else if (ids.hasId(child) || shouldFilter(child, filters)) {
update(ids, commit, child, id, filters, domCache);
// TODO: This is only sometimes necessary
shouldReorder = true;
} else {
mount(ids, commit, child, id, filters, domCache);
shouldReorder = true;
}
}
if (shouldReorder) {
resetChildren(commit, ids, id, vnode, filters);
}
}
/**
* Mark parent vnode for recalculation of children
* @param {import('./types').Commit} commit
* @param {import('./types').IdMapper} ids
* @param {number} id
* @param {import('../../internal').VNode} vnode
* @param {import('./types').FilterState} filters
*/
export function resetChildren(commit, ids, id, vnode, filters) {
let next = getFilteredChildren(vnode, filters);
if (next.length < 2) return;
commit.operations.push(
REORDER_CHILDREN,
id,
next.length,
...next.map(x => ids.getId(x))
);
}
/**
* Traverse over children that are filtered away
* @param {import('../../internal').VNode} vnode
* @param {import('./types').FilterState} filters
* @returns {import('../../internal').VNode[]}
*/
export function getFilteredChildren(vnode, filters) {
const children = getActualChildren(vnode);
const stack = children.slice();
/** @type {import('../../internal').VNode[]} */
const out = [];
/** @type {import('../../internal').VNode<any>} */
let child;
while (stack.length) {
child = /** @type {*} */ (stack.pop());
if (child != null) {
if (!shouldFilter(child, filters)) {
out.push(child);
} else {
const nextChildren = getActualChildren(child);
if (nextChildren.length > 0) {
stack.push(...nextChildren.slice());
}
}
}
}
return out.reverse();
}