routerconsole/jsp/js/diffWorker.js

/**
 * @module diffWorker
 * @description A SharedWorker that parses fragment HTML to a serializable
 * VDOM tree (vdomParser.js) and diffs table rows between snapshots.
 *
 * Receives fragment HTML containing a named tbody whose rows carry data-key
 * attributes (emitted by the server in contentonly fragment mode) and posts
 * back only the changed, inserted, and removed rows, so the main thread
 * patches a handful of rows instead of re-parsing and re-diffing the whole
 * table on every refresh tick.
 *
 * Parsing happens entirely in this worker: vdomParser.js produces a plain
 * data tree ({tagName, attributes, children} / {nodeName, nodeValue}) that
 * survives structured clone, and the main thread realizes only the rows it
 * receives. Row extraction is a tree walk, so nested tables inside a row
 * (renderPeerHTML) need no string depth counting. When row order changes,
 * rows lack keys, or there is no snapshot yet, the result is a "full"
 * fallback carrying the whole tbody VDOM, which the main thread patches
 * wholesale.
 *
 * Without a tbodyId the worker acts as a pure parser and posts the fragment
 * VDOM back ("parsed" action), so the main thread never calls DOMParser.
 * @author dr|z3d
 * @license AGPLv3 or later
 */

importScripts("vdomParser.js");

/** @type {Map<string, {keys: string[], rows: Map<string,string>}>} */
const snapshots = new Map();

/**
 * Finds the first tbody element with the given id in a VDOM tree.
 * @function findTbody
 * @param {Object} root - The parsed VDOM root
 * @param {string} id - The tbody element id
 * @returns {Object|null} The tbody node, or null
 */
function findTbody(root, id) {
  const stack = [root];
  while (stack.length > 0) {
    const node = stack.pop();
    if (node.tagName === "tbody" && node.attributes && node.attributes.id === id) {
      return node;
    }
    const kids = node.children;
    if (Array.isArray(kids)) {
      for (let i = 0; i < kids.length; i++) { stack.push(kids[i]); }
    }
  }
  return null;
}

/**
 * Extracts the direct tr children of a tbody as keyed VDOM entries.
 * @function extractRows
 * @param {Object} tbody - The tbody VDOM node
 * @returns {Array<{key: string|null, vdom: Object}>}
 */
function extractRows(tbody) {
  const rows = [];
  const kids = tbody.children;
  for (let i = 0; i < kids.length; i++) {
    const child = kids[i];
    if (child.tagName === "tr") {
      const attrs = child.attributes || {};
      rows.push({ key: attrs["data-key"] !== undefined ? attrs["data-key"] : null, vdom: child });
    }
  }
  return rows;
}

/**
 * Posts a result to the port that sent the request.
 * @function postResult
 * @param {MessagePort} port - The requesting port
 * @param {string} url - The request url
 * @param {Object} payload - The result payload
 * @returns {void}
 */
function postResult(port, url, payload) {
  port.postMessage(Object.assign({ url }, payload));
}

/**
 * Diffs a new row set against the stored snapshot for the url and posts the
 * result: "unchanged" (nothing to patch), "rows" (changed, inserted, and
 * removed rows as VDOM), or "full" (main thread replaces the whole tbody).
 * @function handleDiff
 * @param {MessagePort} port - The requesting port
 * @param {string} url - The snapshot key
 * @param {string} html - The new fragment HTML
 * @param {string} tbodyId - The tbody element id
 * @returns {void}
 */
function handleDiff(port, url, html, tbodyId) {
  const root = VdomParser.parse(html);
  const tbody = findTbody(root, tbodyId);
  if (!tbody) {
    postResult(port, url, { action: "full" });
    return;
  }
  const rows = extractRows(tbody);
  const keys = rows.map(r => r.key);
  if (keys.some(k => k === null)) {
    postResult(port, url, { action: "full", vdom: tbody });
    return;
  }
  const newRows = new Map(rows.map(r => [r.key, JSON.stringify(r.vdom)]));
  const snapshot = snapshots.get(url);
  if (!snapshot) {
    snapshots.set(url, { keys, rows: newRows });
    postResult(port, url, { action: "full", vdom: tbody });
    return;
  }

  const oldKeys = snapshot.keys;
  const oldRows = snapshot.rows;
  const newKeySet = new Set(keys);
  const oldKeySet = new Set(oldKeys);

  const removed = oldKeys.filter(k => !newKeySet.has(k));
  const inserted = keys.filter(k => !oldKeySet.has(k));

  // Order check: surviving keys must keep their relative order, otherwise
  // the main thread cannot patch incrementally and gets the full fallback.
  const survivorOrder = oldKeys.filter(k => newKeySet.has(k));
  const survivorIndex = new Map(keys.map((k, i) => [k, i]));
  let lastIndex = -1;
  let orderChanged = false;
  for (const k of survivorOrder) {
    const index = survivorIndex.get(k);
    if (index < lastIndex) { orderChanged = true; break; }
    lastIndex = index;
  }

  snapshots.set(url, { keys, rows: newRows });
  if (orderChanged) {
    postResult(port, url, { action: "full", vdom: tbody });
    return;
  }

  const rowVdom = new Map(rows.map(r => [r.key, r.vdom]));
  const changed = [];
  for (const k of survivorOrder) {
    if (newRows.get(k) !== oldRows.get(k)) { changed.push(rowVdom.get(k)); }
  }
  const inserts = inserted.map(k => ({
    key: k,
    vdom: rowVdom.get(k),
    before: keys[keys.indexOf(k) + 1] || null
  }));

  if (changed.length === 0 && inserts.length === 0 && removed.length === 0) {
    postResult(port, url, { action: "unchanged" });
    return;
  }
  postResult(port, url, { action: "rows", rows: changed, inserts, removed });
}

/**
 * Handles new SharedWorker connections.
 * @function self.onconnect
 * @param {MessageEvent} e - The connection event containing ports
 * @returns {void}
 */
self.onconnect = function(e) {
  const port = e.ports[0];
  port.onmessage = function(event) {
    const { url, html, tbodyId } = event.data;
    if (!url || !html) { return; }
    if (tbodyId) {
      handleDiff(port, url, html, tbodyId);
    } else {
      port.postMessage({ url, action: "parsed", vdom: VdomParser.parse(html) });
    }
  };
};