preact
Version:
Fast 4kb React-compatible Virtual DOM library.
803 lines (714 loc) • 23.6 kB
JavaScript
import { BaseComponent, getDomSibling } from '../component';
import {
COMPONENT_DIRTY,
COMPONENT_FORCE,
COMPONENT_PENDING_ERROR,
COMPONENT_PROCESSING_EXCEPTION,
EMPTY_ARR,
EMPTY_OBJ,
FORCE_PROPS_REVALIDATE,
MATHML_TOKEN_ELEMENTS,
MATH_NAMESPACE,
MODE_HYDRATE,
MODE_SUSPENDED,
NULL,
RESET_MODE,
SVG_NAMESPACE,
UNDEFINED,
XHTML_NAMESPACE
} from '../constants';
import { Fragment } from '../create-element';
import options from '../options';
import { assign, isArray, removeNode, slice } from '../util';
import { diffChildren } from './children';
import { setProperty } from './props';
/**
* @typedef {import('../internal').ComponentType} ComponentType
* @typedef {import('../internal').ComponentChildren} ComponentChildren
* @typedef {import('../internal').Component} Component
* @typedef {import('../internal').PreactElement} PreactElement
* @typedef {import('../internal').VNode} VNode
*/
/**
* @template {any} T
* @typedef {import('../internal').Ref<T>} Ref<T>
*/
/**
* Diff two virtual nodes and apply proper changes to the DOM
* @param {PreactElement} parentDom The parent of the DOM element
* @param {VNode} newVNode The new virtual node
* @param {VNode} oldVNode The old virtual node
* @param {object} globalContext The current context object. Modified by
* getChildContext
* @param {string} namespace Current namespace of the DOM node (HTML, SVG, or MathML)
* @param {Array<PreactElement>} excessDomChildren
* @param {Array<Component>} commitQueue List of components which have callbacks
* to invoke in commitRoot
* @param {PreactElement} oldDom The current attached DOM element any new dom
* elements should be placed around. Likely `null` on first render (except when
* hydrating). Can be a sibling DOM element when diffing Fragments that have
* siblings. In most cases, it starts out as `oldChildren[0]._dom`.
* @param {boolean} isHydrating Whether or not we are in hydration
* @param {any[]} refQueue an array of elements needed to invoke refs
*/
export function diff(
parentDom,
newVNode,
oldVNode,
globalContext,
namespace,
excessDomChildren,
commitQueue,
oldDom,
isHydrating,
refQueue
) {
/** @type {any} */
let tmp,
resumed,
newType = newVNode.type;
// When passing through createElement it assigns the object
// constructor as undefined. This to prevent JSON-injection.
if (newVNode.constructor !== UNDEFINED) return NULL;
// If the previous diff bailed out, resume creating/hydrating into the DOM
// that was kept while suspended (server-rendered, or already in the
// container we rendered into). `tmp` holds the stored excess node until the
// options._diff call below.
if (
oldVNode._flags & MODE_SUSPENDED &&
// @ts-expect-error This is MODE_HYDRATE or 0
((isHydrating = oldVNode._flags & MODE_HYDRATE),
(tmp = oldVNode._component._excess))
) {
newVNode._flags |= isHydrating;
resumed = excessDomChildren = [];
if (tmp.nodeType == 8) {
// Re-scan DOM from stored start marker for streamed hydration.
// `depth` only ever reaches 0 through the `break` below, so it
// doesn't need to be re-tested in the loop condition.
for (
let depth = 1, node = tmp.nextSibling;
node;
node = node.nextSibling
) {
if (node.nodeType == 8) {
if (node.data.startsWith('$s')) depth++;
else if (node.data.startsWith('/$s') && !--depth) break;
}
excessDomChildren.push(node);
}
} else {
excessDomChildren.push(tmp);
}
oldDom = excessDomChildren[0];
}
if ((tmp = options._diff)) tmp(newVNode);
outer: if (typeof newType == 'function') {
let oldCommitQueueLength = commitQueue.length;
try {
let c,
oldProps,
oldState,
snapshot,
newProps = newVNode.props;
const isClassComponent = (tmp = newType.prototype) && tmp.render;
// Necessary for createContext api. Setting this property will pass
// the context value as `this.context` just for this component.
tmp = newType.contextType;
const provider = tmp && globalContext[tmp._id];
const componentContext = tmp
? provider
? provider.props.value
: tmp._defaultValue
: globalContext;
// Get component and set it to `c`
if (oldVNode._component) {
c = newVNode._component = oldVNode._component;
if (c._bits & COMPONENT_PENDING_ERROR) {
c._bits |= COMPONENT_PROCESSING_EXCEPTION;
}
} else {
// Instantiate the new component
if (isClassComponent) {
// @ts-expect-error Trust me, Component implements the interface we want
newVNode._component = c = new newType(newProps, componentContext); // eslint-disable-line new-cap
} else {
// @ts-expect-error Trust me, Component implements the interface we want
newVNode._component = c = new BaseComponent(
newProps,
componentContext
);
c.constructor = newType;
c.render = doRender;
}
if (provider) provider.sub(c);
if (!c.state) c.state = {};
c._globalContext = globalContext;
c._bits |= COMPONENT_DIRTY;
c._renderCallbacks = [];
c._stateCallbacks = [];
}
if (isClassComponent) {
if (!c._nextState) c._nextState = c.state;
// Invoke getDerivedStateFromProps
if (newType.getDerivedStateFromProps) {
if (c._nextState == c.state) {
c._nextState = assign({}, c._nextState);
}
assign(
c._nextState,
newType.getDerivedStateFromProps(newProps, c._nextState)
);
}
}
oldProps = c.props;
oldState = c.state;
c._vnode = newVNode;
// Invoke pre-render lifecycle methods
if (!oldVNode._component) {
if (
isClassComponent &&
!newType.getDerivedStateFromProps &&
c.componentWillMount
) {
c.componentWillMount();
}
if (isClassComponent && c.componentDidMount) {
c._renderCallbacks.push(c.componentDidMount);
}
} else {
if (
isClassComponent &&
!newType.getDerivedStateFromProps &&
newProps !== oldProps &&
c.componentWillReceiveProps
) {
c.componentWillReceiveProps(newProps, componentContext);
}
if (
(newVNode._original == oldVNode._original &&
!(c._bits & COMPONENT_DIRTY)) ||
(!(c._bits & COMPONENT_FORCE) &&
c.shouldComponentUpdate &&
c.shouldComponentUpdate(
newProps,
c._nextState,
componentContext
) === false)
) {
// More info about this here: https://gist.github.com/JoviDeCroock/bec5f2ce93544d2e6070ef8e0036e4e8
if (newVNode._original != oldVNode._original) {
// When we are dealing with a bail because of sCU we have to update
// the props, state and dirty-state.
// when we are dealing with strict-equality we don't as the child could still
// be dirtied see #3883
c.props = newProps;
c.state = c._nextState;
c._bits &= ~COMPONENT_DIRTY;
}
newVNode._dom = oldVNode._dom;
newVNode._children = oldVNode._children;
newVNode._children.some(vnode => {
if (vnode) vnode._parent = newVNode;
});
EMPTY_ARR.push.apply(c._renderCallbacks, c._stateCallbacks);
c._stateCallbacks = [];
if (c._renderCallbacks.length) {
commitQueue.push(c);
}
// Skip over the retained subtree without traversing it; the
// `result` branch in diffChildren picks this up as the next
// oldDom.
oldDom = getDomSibling(oldVNode);
break outer;
}
if (c.componentWillUpdate) {
c.componentWillUpdate(newProps, c._nextState, componentContext);
}
if (isClassComponent && c.componentDidUpdate) {
c._renderCallbacks.push(() => {
c.componentDidUpdate(oldProps, oldState, snapshot);
});
}
}
c.context = componentContext;
c.props = newProps;
c._parentDom = parentDom;
c._bits &= ~COMPONENT_FORCE;
let renderHook = options._render,
count = 0;
if (isClassComponent) {
c.state = c._nextState;
c._bits &= ~COMPONENT_DIRTY;
if (renderHook) renderHook(newVNode);
tmp = c.render(c.props, c.state, c.context);
EMPTY_ARR.push.apply(c._renderCallbacks, c._stateCallbacks);
c._stateCallbacks = [];
} else {
do {
c._bits &= ~COMPONENT_DIRTY;
if (renderHook) renderHook(newVNode);
tmp = c.render(c.props, c.state, c.context);
// Handle setState called in render, see #2553
c.state = c._nextState;
} while (c._bits & COMPONENT_DIRTY && ++count < 25);
}
// Handle setState called in render, see #2553
c.state = c._nextState;
if (c.getChildContext) {
globalContext = assign({}, globalContext, c.getChildContext());
}
if (
isClassComponent &&
oldVNode._component &&
c.getSnapshotBeforeUpdate
) {
snapshot = c.getSnapshotBeforeUpdate(oldProps, oldState);
}
const renderResult =
tmp && tmp.type === Fragment && tmp.key == NULL
? tmp.props.children
: tmp;
// A vnode carrying a `_parentDom` prop, considered a new root,
// continues rendering into that container. The subtree must stay
// invisible to the host tree's DOM bookkeeping: the portal vnode
// keeps a `null` `_dom` and the host insertion cursor (`oldDom`)
// passes through unchanged.
// `tmp` (the raw render result, already unwrapped into renderResult)
// doubles as the saved host insertion cursor while we render into
// the portal's container.
if (newProps._parentDom) {
tmp = oldDom;
parentDom = newProps._parentDom;
// If we portal into a math or svg element we need
// to swap the namespace (chart libraries often do this).
// The document follows the container too (e.g. iframes) as
// diffElementNodes derives it from its parentDom.
namespace = parentDom.namespaceURI;
// Portals are never server-rendered: hydration must not claim the
// host parent's excess nodes for the container's children.
isHydrating = excessDomChildren = NULL;
// Changing the container remounts the children into the new one
if (oldVNode.props && oldVNode.props._parentDom != parentDom) {
/** @type {VNode[]} */ (oldVNode._children).some(child => {
if (child) unmount(child, child);
});
oldVNode._children = NULL;
}
oldDom = oldVNode._children ? getDomSibling(oldVNode, 0) : NULL;
}
oldDom = diffChildren(
parentDom,
isArray(renderResult) ? renderResult : [renderResult],
newVNode,
oldVNode,
globalContext,
namespace,
excessDomChildren,
commitQueue,
oldDom,
isHydrating,
refQueue
);
// When we exit a portal we
// change up the oldDom
if (newProps._parentDom) {
newVNode._dom = NULL;
oldDom = tmp;
}
// We successfully rendered this VNode, unset any stored hydration/bailout state:
newVNode._flags &= RESET_MODE;
if (oldVNode._flags & MODE_SUSPENDED) c._excess = NULL;
// Remove the kept DOM this resumed render didn't claim
if (resumed) resumed.some(removeNode);
if (c._renderCallbacks.length) {
commitQueue.push(c);
}
// Only clear when this render was the retry; PENDING_ERROR set by a
// descendant during this render must survive until then.
if (c._bits & COMPONENT_PROCESSING_EXCEPTION) {
c._bits &= ~(COMPONENT_PROCESSING_EXCEPTION | COMPONENT_PENDING_ERROR);
}
} catch (e) {
// We remove any componentDidMount, ...
// that have been invalidated by us
// intercepting the error.
commitQueue.length = oldCommitQueueLength;
newVNode._original = NULL;
// if hydrating or creating initial tree, bailout preserves DOM:
if (isHydrating || excessDomChildren) {
if (e.then) {
let commentMarkersToFind = 0,
startMarker;
newVNode._flags |= isHydrating
? MODE_HYDRATE | MODE_SUSPENDED
: MODE_SUSPENDED;
if (excessDomChildren) {
for (let i = 0; i < excessDomChildren.length; i++) {
let child = excessDomChildren[i];
if (!child) continue;
if (child.nodeType == 8) {
excessDomChildren[i] = NULL;
if (child.data.startsWith('$s')) {
if (!commentMarkersToFind++) startMarker = child;
} else if (
child.data.startsWith('/$s') &&
!--commentMarkersToFind
) {
oldDom = child;
break;
}
} else if (commentMarkersToFind) {
excessDomChildren[i] = NULL;
}
}
}
if (!startMarker) {
while (oldDom && oldDom.nodeType == 8 && oldDom.nextSibling) {
oldDom = oldDom.nextSibling;
}
if (excessDomChildren) {
excessDomChildren[excessDomChildren.indexOf(oldDom)] = NULL;
}
startMarker = oldDom;
}
// Store the start marker directly; children re-scanned on resume.
// A re-suspension keeps the marker it originally started from.
if (!newVNode._component._excess) {
newVNode._component._excess = startMarker;
}
newVNode._dom = oldDom;
} else if (excessDomChildren) {
excessDomChildren.some(removeNode);
}
} else {
newVNode._dom = oldVNode._dom;
}
if (!newVNode._children) {
newVNode._children = oldVNode._children || [];
}
if (!e.then) markAsForce(newVNode);
options._catchError(e, newVNode, oldVNode);
}
} else {
oldDom = newVNode._dom = diffElementNodes(
oldVNode._dom,
newVNode,
oldVNode,
globalContext,
namespace,
excessDomChildren,
commitQueue,
isHydrating,
refQueue,
parentDom
);
}
if ((tmp = options.diffed)) tmp(newVNode);
return newVNode._flags & MODE_SUSPENDED ? UNDEFINED : oldDom;
}
function markAsForce(vnode) {
if (vnode) {
if (vnode._component) vnode._component._bits |= COMPONENT_FORCE;
if (vnode._children) vnode._children.some(markAsForce);
}
}
/**
* @param {Array<Component>} commitQueue List of components
* which have callbacks to invoke in commitRoot
* @param {VNode} root
*/
export function commitRoot(commitQueue, root, refQueue) {
for (let i = 0; i < refQueue.length; ) {
applyRef(refQueue[i++], refQueue[i++], refQueue[i++]);
}
if (options._commit) options._commit(root, commitQueue);
commitQueue.some(c => {
try {
// @ts-expect-error Reuse the commitQueue variable here so the type changes
commitQueue = c._renderCallbacks;
c._renderCallbacks = [];
commitQueue.some(cb => {
// @ts-expect-error See above comment on commitQueue
cb.call(c);
});
} catch (e) {
options._catchError(e, c._vnode);
}
});
}
/**
* Diff two virtual nodes representing DOM element
* @param {PreactElement} dom The DOM element representing the virtual nodes
* being diffed
* @param {VNode} newVNode The new virtual node
* @param {VNode} oldVNode The old virtual node
* @param {object} globalContext The current context object
* @param {string} namespace Current namespace of the DOM node (HTML, SVG, or MathML)
* @param {Array<PreactElement>} excessDomChildren
* @param {Array<Component>} commitQueue List of components which have callbacks
* to invoke in commitRoot
* @param {boolean} isHydrating Whether or not we are in hydration
* @param {any[]} refQueue an array of elements needed to invoke refs
* @param {PreactElement} parentDom The parent this element will be inserted
* into; new nodes are created from its ownerDocument (e.g. iframes), or from
* the global document for containers that don't have one
* @returns {PreactElement}
*/
function diffElementNodes(
dom,
newVNode,
oldVNode,
globalContext,
namespace,
excessDomChildren,
commitQueue,
isHydrating,
refQueue,
parentDom
) {
let oldProps = oldVNode.props || EMPTY_OBJ;
const newProps = newVNode.props;
const nodeType = /** @type {string} */ (newVNode.type);
/** @type {any} */
let i;
/** @type {{ __html?: string }} */
let newHtml;
/** @type {{ __html?: string }} */
let oldHtml;
/** @type {ComponentChildren} */
let newChildren;
let value;
let inputValue;
let checked;
// Tracks entering and exiting namespaces when descending through the tree.
if (nodeType == 'svg') namespace = SVG_NAMESPACE;
else if (nodeType == 'math') namespace = MATH_NAMESPACE;
else if (!namespace) namespace = XHTML_NAMESPACE;
if (excessDomChildren) {
for (i = 0; i < excessDomChildren.length; i++) {
value = excessDomChildren[i];
// if newVNode matches an element in excessDomChildren or the `dom`
// argument matches an element in excessDomChildren, remove it from
// excessDomChildren so it isn't later removed in diffChildren.
// `localName` is only present on elements, so it doubles as the
// element check for the non-text branch.
if (
value &&
(nodeType ? value.localName == nodeType : value.nodeType == 3)
) {
dom = value;
excessDomChildren[i] = NULL;
break;
}
}
}
if (!dom) {
const doc = parentDom.ownerDocument || document;
if (!nodeType) {
return doc.createTextNode(newProps);
}
dom = doc.createElementNS(namespace, nodeType, newProps.is && newProps);
// we are creating a new node, so we can assume this is a new subtree (in
// case we are hydrating), this deopts the hydrate
if (isHydrating) {
if (options._hydrationMismatch)
options._hydrationMismatch(newVNode, excessDomChildren);
isHydrating = false;
}
// we created a new parent, so none of the previously attached children can be reused:
excessDomChildren = NULL;
}
if (!nodeType) {
// During hydration, we still have to split merged text from SSR'd HTML.
if (oldProps !== newProps && (!isHydrating || dom.data != newProps)) {
dom.data = newProps;
}
} else {
// A <template> holds its children in a separate document-fragment, both
// when reading existing DOM and when inserting new nodes. `parentDom` is
// unused from here on, so we reuse it as the container for our children.
// @ts-expect-error
parentDom = nodeType == 'template' ? dom.content : dom;
// If excessDomChildren was not null, repopulate it with the current element's children:
excessDomChildren =
nodeType == 'textarea' && newProps.defaultValue != NULL
? NULL
: excessDomChildren && slice.call(parentDom.childNodes);
// If we are in a situation where we are not hydrating but are using
// existing DOM (e.g. replaceNode) we should read the existing DOM
// attributes to diff them
if (!isHydrating && excessDomChildren) {
// Keep this a plain loop: an arrow function here would capture
// `oldProps`, forcing V8 to context-allocate it and slowing down the
// hot `for (i in oldProps)` loop below for every element we diff.
oldProps = {};
for (i = 0; i < dom.attributes.length; i++) {
value = dom.attributes[i];
oldProps[value.name] = value.value;
}
}
for (i in oldProps) {
value = oldProps[i];
if (i == 'dangerouslySetInnerHTML') {
oldHtml = value;
} else if (
i != 'children' &&
!(i in newProps) &&
!(i == 'value' && 'defaultValue' in newProps) &&
!(i == 'checked' && 'defaultChecked' in newProps)
) {
setProperty(dom, i, NULL, value, namespace);
}
}
// During hydration, props are not diffed at all (including dangerouslySetInnerHTML)
// @TODO we should warn in debug mode when props don't match here.
const shouldRevalidateProps = oldVNode._flags & FORCE_PROPS_REVALIDATE;
for (i in newProps) {
value = newProps[i];
if (i == 'children') {
newChildren = value;
} else if (i == 'dangerouslySetInnerHTML') {
newHtml = value;
} else if (i == 'value') {
inputValue = value;
} else if (i == 'checked') {
checked = value;
} else if (
(!isHydrating || typeof value == 'function') &&
(oldProps[i] !== value || (shouldRevalidateProps && value != NULL))
) {
setProperty(dom, i, value, oldProps[i], namespace);
}
}
// If the new vnode didn't have dangerouslySetInnerHTML, diff its children
if (newHtml) {
// Avoid re-applying the same '__html' if it did not changed between re-render
if (
!isHydrating &&
(!oldHtml ||
(newHtml.__html != oldHtml.__html && newHtml.__html != dom.innerHTML))
) {
dom.innerHTML = newHtml.__html;
}
newVNode._children = [];
} else {
if (oldHtml) dom.textContent = '';
if (
nodeType == 'foreignObject' ||
(namespace == MATH_NAMESPACE && MATHML_TOKEN_ELEMENTS.test(nodeType))
) {
namespace = XHTML_NAMESPACE;
}
diffChildren(
parentDom,
isArray(newChildren) ? newChildren : [newChildren],
newVNode,
oldVNode,
globalContext,
namespace,
excessDomChildren,
commitQueue,
excessDomChildren
? excessDomChildren[0]
: oldVNode._children && getDomSibling(oldVNode, 0),
isHydrating,
refQueue
);
// Remove children that are not part of any vnode.
if (excessDomChildren) excessDomChildren.some(removeNode);
}
// As above, don't diff props during hydration
if (!isHydrating || nodeType == 'textarea') {
i = 'value';
if (nodeType == 'progress' && inputValue == NULL) {
dom.removeAttribute(i);
} else if (
inputValue != UNDEFINED &&
// #2756 For the <progress>-element the initial value is 0,
// despite the attribute not being present. When the attribute
// is missing the progress bar is treated as indeterminate.
// To fix that we'll always update it when it is 0 for progress elements
(inputValue !== dom[i] || (nodeType == 'progress' && !inputValue))
) {
setProperty(dom, i, inputValue, oldProps[i], namespace);
}
i = 'checked';
if (checked != UNDEFINED && checked != dom[i]) {
setProperty(dom, i, checked, oldProps[i], namespace);
}
}
}
return dom;
}
/**
* Invoke or update a ref, depending on whether it is a function or object ref.
* @param {Ref<any> & { _unmount?: unknown }} ref
* @param {any} value
* @param {VNode} vnode
*/
export function applyRef(ref, value, vnode) {
try {
if (typeof ref == 'function') {
if (typeof ref._unmount == 'function') {
ref._unmount();
}
if (typeof ref._unmount != 'function' || value) {
// Store the cleanup function on the function
// instance object itself to avoid shape
// transitioning vnode
ref._unmount = ref(value);
}
} else ref.current = value;
} catch (e) {
options._catchError(e, vnode);
}
}
/**
* Unmount a virtual node from the tree and apply DOM changes
* @param {VNode} vnode The virtual node to unmount
* @param {VNode} parentVNode The parent of the VNode that initiated the unmount
* @param {boolean} [skipRemove] Flag that indicates that a parent node of the
* current element is already detached from the DOM.
*/
export function unmount(vnode, parentVNode, skipRemove) {
let r;
if (options.unmount) options.unmount(vnode);
if ((r = vnode.ref) && (!r.current || r.current == vnode._dom)) {
applyRef(r, NULL, parentVNode);
}
if ((r = vnode._component)) {
if (r.componentWillUnmount) {
try {
r.componentWillUnmount();
} catch (e) {
options._catchError(e, parentVNode);
}
}
r._parentDom = r._globalContext = NULL;
}
if ((r = vnode._children)) {
for (let i = 0; i < r.length; i++) {
if (r[i]) {
unmount(
r[i],
parentVNode,
// A portal's children live in another container, so a removed
// host ancestor doesn't detach them; always remove them.
typeof vnode.type == 'function'
? skipRemove && !vnode.props._parentDom
: true
);
}
}
}
if ((r = vnode._dom)) {
if (!skipRemove) removeNode(r);
if (r._listeners) r._listeners = NULL;
}
vnode._dom = vnode._component = vnode._parent = NULL;
}
/** The `.render()` method for a PFC backing instance. */
function doRender(props, state, context) {
return this.constructor(props, context);
}