All files / client/src/hydration patch.js

89.92% Statements 125/139
81.39% Branches 105/129
94.73% Functions 18/19
90.43% Lines 104/115

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295                                                      9x     9x             3081x 9243x   5x 5x 3x 3x   2x 2x           3081x 2029x   3081x 2052x         1x                                   31x 13x                         38x 38x 38x       38x 38x   38x 38x 27x   38x       3044x       3036x 1020x 1020x       3036x 3026x 3026x 3029x 3029x 5x   2x       2015x       4028x         4032x 4032x 4032x               3035x 3035x 4032x 4032x 5x   4032x                 4027x   4027x 2014x 2014x     2013x           2012x 2012x     1x       3043x 3043x 3043x 3043x 3043x     3043x 3043x 3043x 1x 1x           3042x   4032x   3043x   7x 7x 7x 7x   7x                 3035x   3035x 3x 1x 2x 2x 2x 2x 2x         1x   3034x 3034x 4027x 4027x 4027x   3034x 3034x 3x       3035x                     3043x 3043x   3043x 3043x 3043x                     1022x       1022x       1022x 1022x       1022x 1022x    
/**
 * DOM patching for hydrated components.
 *
 * Diffs the previous virtual tree against the next one and applies the
 * difference to the DOM the previous tree produced: attributes and live form
 * properties, text, raw HTML, and children — added, removed, replaced, and
 * matched by `key` when every sibling has one.
 *
 * Attributes are the ones core's renderer writes (see renderedAttributes()
 * in vnode.js): functions are called, class arrays and objects are joined,
 * style objects become `prop: value` declarations, `true` is a bare attribute
 * and `false`/`null`/`undefined` remove it (aria-* and enumerated attributes
 * get "true"/"false"). `key`, `children`, `text`, `html` and `on*` handlers
 * are never attributes.
 *
 * @module @coherent.js/client/hydration/patch
 */
 
import {
  isElementVNode,
  readElement,
  getRenderedChildren,
  getSignificantDOMChildren,
  resolveAttributeValue,
  renderedAttributes,
} from './vnode.js';
 
const SVG_NS = 'http://www.w3.org/2000/svg';
 
/** Form state that lives in properties once the user has touched a field. */
const LIVE_PROPERTIES = new Set(['value', 'checked', 'selected']);
 
/**
 * Bring an element's live form properties in line with its props. Setting the
 * attribute alone does not change a field the user has edited.
 */
function applyLiveProperties(element, props) {
  for (const name of LIVE_PROPERTIES) {
    if (!(name in props) || !(name in element)) continue;
 
    const value = resolveAttributeValue(props[name]);
    if (name === 'value') {
      const next = value === null || value === undefined ? '' : String(value);
      if (element.value !== next) element.value = next;
    } else {
      const next = Boolean(value);
      Eif (element[name] !== next) element[name] = next;
    }
  }
}
 
function setAttributes(element, previous, next) {
  for (const name of previous.keys()) {
    if (!next.has(name)) element.removeAttribute(name);
  }
  for (const [name, value] of next) {
    if (element.getAttribute(name) !== value) element.setAttribute(name, value);
  }
}
 
function setRawHTML(element, html) {
  Eif (element.innerHTML !== html) element.innerHTML = html;
}
 
/**
 * Create DOM nodes for raw HTML.
 * @returns {Node[]}
 */
function nodesFromHTML(html, doc) {
  const holder = doc.createElement('div');
  holder.innerHTML = html;
  return Array.from(holder.childNodes);
}
 
/**
 * Create DOM nodes for one rendered child (see getRenderedChildren).
 * @returns {Node[]}
 */
function createNodes(item, doc, namespace) {
  if (item.type === 'text') return [doc.createTextNode(item.text)];
  Eif (item.type === 'element') return [createElement(item.vNode, doc, namespace)];
  // A component taking arguments cannot be run here; it renders nothing.
  return item.html === undefined ? [] : nodesFromHTML(item.html, doc);
}
 
/**
 * Create a DOM element (and its subtree) from an element virtual node.
 * @param {Object} vNode
 * @param {Document} [doc=document]
 * @param {string|null} [namespace]
 * @returns {Element}
 */
export function createElement(vNode, doc = document, namespace = null) {
  const { tagName, props } = readElement(vNode);
  const ns = tagName.toLowerCase() === 'svg' ? SVG_NS : namespace;
  const element = ns && typeof doc.createElementNS === 'function'
    ? doc.createElementNS(ns, tagName)
    : doc.createElement(tagName);
 
  setAttributes(element, new Map(), renderedAttributes(props));
  applyLiveProperties(element, props);
 
  const childNs = tagName.toLowerCase() === 'foreignobject' ? null : ns;
  for (const item of getRenderedChildren(tagName, props)) {
    for (const node of createNodes(item, doc, childNs)) element.appendChild(node);
  }
  return element;
}
 
function namespaceOf(element) {
  return element.namespaceURI === SVG_NS && element.localName !== 'foreignObject' ? SVG_NS : null;
}
 
function keyOf(item) {
  if (item.type !== 'element') return undefined;
  const { props } = readElement(item.vNode);
  return props.key;
}
 
function allKeyed(list) {
  if (list.length === 0) return false;
  const keys = new Set();
  for (const item of list) {
    const key = keyOf(item);
    if (key === undefined || key === null || keys.has(key)) return false;
    keys.add(key);
  }
  return true;
}
 
function sameTag(a, b) {
  return Object.keys(a)[0].toLowerCase() === Object.keys(b)[0].toLowerCase();
}
 
function isInsignificant(node) {
  return node.nodeType === 8 || (node.nodeType === 3 && node.textContent.trim() === '');
}
 
/** The first sibling from `node` on that is an element or non-blank text. */
function significantFrom(node) {
  let current = node;
  while (current && isInsignificant(current)) current = current.nextSibling;
  return current;
}
 
/**
 * Put `nodes` in order as the element's significant children, moving only
 * the nodes that are out of place (a moved field loses focus).
 */
function placeInOrder(parent, nodes) {
  let previous = null;
  for (const node of nodes) {
    const expected = significantFrom(previous ? previous.nextSibling : parent.firstChild);
    if (node !== expected) {
      parent.insertBefore(node, previous ? previous.nextSibling : parent.firstChild);
    }
    previous = node;
  }
}
 
/**
 * Patch one child: reuse the DOM node when the kind (and tag) matches,
 * otherwise create a replacement. Returns the node now representing `next`.
 */
function patchChild(parent, node, previous, next) {
  const doc = parent.ownerDocument ?? globalThis.document;
 
  if (previous.type === 'text' && next.type === 'text' && node.nodeType === 3) {
    if (node.textContent !== next.text) node.textContent = next.text;
    return [node];
  }
 
  if (
    previous.type === 'element' &&
    next.type === 'element' &&
    node.nodeType === 1 &&
    sameTag(previous.vNode, next.vNode)
  ) {
    patchElement(node, previous.vNode, next.vNode);
    return [node];
  }
 
  return createNodes(next, doc, namespaceOf(parent));
}
 
function patchChildren(element, previousProps, nextProps, tagName) {
  const doc = element.ownerDocument ?? globalThis.document;
  const previousList = getRenderedChildren(tagName, previousProps);
  const nextList = getRenderedChildren(tagName, nextProps);
  const domList = getSignificantDOMChildren(element);
  const namespace = namespaceOf(element);
 
  // Raw HTML: set it when it changed, leave it alone otherwise.
  const previousHTML = previousList.length === 1 && previousList[0].type === 'opaque' ? previousList[0].html : undefined;
  const nextHTML = nextList.length === 1 && nextList[0].type === 'opaque' ? nextList[0].html : undefined;
  if (nextProps.html !== undefined && nextHTML !== undefined) {
    Eif (previousProps.html === undefined || previousHTML !== nextHTML) setRawHTML(element, nextHTML);
    return;
  }
 
  // Positions are only reliable when the DOM holds what the previous tree
  // rendered, one node per child; otherwise rebuild the children.
  const reliable =
    previousList.length === domList.length &&
    !previousList.some((item) => item.type === 'opaque') &&
    !nextList.some((item) => item.type === 'opaque');
 
  if (!reliable) {
    // Text-only content needs no node creation
    Eif (nextList.length === 0 || (nextList.length === 1 && nextList[0].type === 'text')) {
      const text = nextList.length === 0 ? '' : nextList[0].text;
      Eif (element.textContent !== text || domList.length !== nextList.length) {
        element.textContent = text;
      }
      return;
    }
    for (const node of Array.from(element.childNodes)) element.removeChild(node);
    for (const item of nextList) {
      for (const node of createNodes(item, doc, namespace)) element.appendChild(node);
    }
    return;
  }
 
  const nextNodes = [];
 
  if (allKeyed(previousList) && allKeyed(nextList)) {
    const byKey = new Map(previousList.map((item, i) => [keyOf(item), { item, node: domList[i] }]));
    for (const item of nextList) {
      const match = byKey.get(keyOf(item));
      if (match && sameTag(match.item.vNode, item.vNode)) {
        byKey.delete(keyOf(item));
        patchElement(match.node, match.item.vNode, item.vNode);
        nextNodes.push(match.node);
      } else E{
        nextNodes.push(...createNodes(item, doc, namespace));
      }
    }
    for (const { node } of byKey.values()) element.removeChild(node);
  } else {
    const common = Math.min(previousList.length, nextList.length);
    for (let i = 0; i < common; i++) {
      const nodes = patchChild(element, domList[i], previousList[i], nextList[i]);
      if (nodes[0] !== domList[i]) element.removeChild(domList[i]);
      nextNodes.push(...nodes);
    }
    for (let i = common; i < domList.length; i++) element.removeChild(domList[i]);
    for (let i = common; i < nextList.length; i++) {
      nextNodes.push(...createNodes(nextList[i], doc, namespace));
    }
  }
 
  placeInOrder(element, nextNodes);
}
 
/**
 * Patch `element`, which the server or a previous patch rendered from
 * `previousVNode`, to match `nextVNode`. Tags must match; see patchRoot().
 * @param {Element} element
 * @param {Object} previousVNode
 * @param {Object} nextVNode
 */
export function patchElement(element, previousVNode, nextVNode) {
  const previous = readElement(previousVNode);
  const next = readElement(nextVNode);
 
  setAttributes(element, renderedAttributes(previous.props), renderedAttributes(next.props));
  applyLiveProperties(element, next.props);
  patchChildren(element, previous.props, next.props, next.tagName);
}
 
/**
 * Patch a component's root element, replacing it when the tag changed.
 * @param {Element} element - Current root element
 * @param {*} previousVNode - Tree `element` was rendered from
 * @param {*} nextVNode - Tree to render
 * @returns {Element} The root element afterwards (a new one if replaced)
 */
export function patchRoot(element, previousVNode, nextVNode) {
  Iif (!isElementVNode(nextVNode)) {
    return element;
  }
 
  Eif (
    isElementVNode(previousVNode) &&
    element.tagName?.toLowerCase() === Object.keys(nextVNode)[0].toLowerCase()
  ) {
    patchElement(element, previousVNode, nextVNode);
    return element;
  }
 
  const replacement = createElement(nextVNode, element.ownerDocument ?? document, namespaceOf(element.parentNode ?? element));
  element.parentNode?.replaceChild(replacement, element);
  return replacement;
}