Skip to content

Instantly share code, notes, and snippets.

@delaneyj
Last active November 4, 2024 03:11
Show Gist options
  • Save delaneyj/18f4e3e090da15590a0f2862a6314b66 to your computer and use it in GitHub Desktop.
Save delaneyj/18f4e3e090da15590a0f2862a6314b66 to your computer and use it in GitHub Desktop.
[{
"message": "Argument of type 'Node | Element | null | undefined' is not assignable to parameter of type 'Node | Element | null'.\n Type 'undefined' is not assignable to type 'Node | Element | null'.",
"startLineNumber": 193,
"startColumn": 52,
"endLineNumber": 193,
"endColumn": 63
},{
"message": "Type 'boolean | undefined' is not assignable to type 'boolean'.\n Type 'undefined' is not assignable to type 'boolean'.",
"startLineNumber": 211,
"startColumn": 5,
"endLineNumber": 211,
"endColumn": 11
},{
"message": "Argument of type 'Node' is not assignable to parameter of type 'Element'.\n Type 'Node' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 119 more.",
"startLineNumber": 229,
"startColumn": 38,
"endLineNumber": 229,
"endColumn": 48
},{
"message": "'oldNode.parentElement' is possibly 'null'.",
"startLineNumber": 233,
"startColumn": 9,
"endLineNumber": 233,
"endColumn": 30
},{
"message": "Argument of type 'Node' is not assignable to parameter of type 'Element'.\n Type 'Node' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 119 more.",
"startLineNumber": 247,
"startColumn": 31,
"endLineNumber": 247,
"endColumn": 41
},{
"message": "Argument of type 'Node' is not assignable to parameter of type 'Element'.\n Type 'Node' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 119 more.",
"startLineNumber": 249,
"startColumn": 26,
"endLineNumber": 249,
"endColumn": 36
},{
"message": "Type 'DocumentFragment' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 110 more.",
"startLineNumber": 283,
"startColumn": 9,
"endLineNumber": 283,
"endColumn": 18
},{
"message": "Argument of type 'Node' is not assignable to parameter of type 'Element'.\n Type 'Node' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 119 more.",
"startLineNumber": 306,
"startColumn": 26,
"endLineNumber": 306,
"endColumn": 34
},{
"message": "Argument of type 'Node' is not assignable to parameter of type 'Element'.\n Type 'Node' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 119 more.",
"startLineNumber": 307,
"startColumn": 28,
"endLineNumber": 307,
"endColumn": 42
},{
"message": "Argument of type 'Node' is not assignable to parameter of type 'Element'.\n Type 'Node' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 119 more.",
"startLineNumber": 329,
"startColumn": 28,
"endLineNumber": 329,
"endColumn": 38
},{
"message": "Argument of type 'Node' is not assignable to parameter of type 'Element'.\n Type 'Node' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 119 more.",
"startLineNumber": 346,
"startColumn": 28,
"endLineNumber": 346,
"endColumn": 37
},{
"message": "Argument of type 'Node' is not assignable to parameter of type 'Element'.\n Type 'Node' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 119 more.",
"startLineNumber": 364,
"startColumn": 20,
"endLineNumber": 364,
"endColumn": 28
},{
"message": "Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Element'.\n No index signature with a parameter of type 'string' was found on type 'Element'.",
"startLineNumber": 439,
"startColumn": 9,
"endLineNumber": 439,
"endColumn": 28
},{
"message": "Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Element'.\n No index signature with a parameter of type 'string' was found on type 'Element'.",
"startLineNumber": 439,
"startColumn": 33,
"endLineNumber": 439,
"endColumn": 50
},{
"message": "Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Element'.\n No index signature with a parameter of type 'string' was found on type 'Element'.",
"startLineNumber": 442,
"startColumn": 13,
"endLineNumber": 442,
"endColumn": 30
},{
"message": "Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Element'.\n No index signature with a parameter of type 'string' was found on type 'Element'.",
"startLineNumber": 442,
"startColumn": 33,
"endLineNumber": 442,
"endColumn": 52
},{
"message": "Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Element'.\n No index signature with a parameter of type 'string' was found on type 'Element'.",
"startLineNumber": 444,
"startColumn": 13,
"endLineNumber": 444,
"endColumn": 32
},{
"message": "Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Element'.\n No index signature with a parameter of type 'string' was found on type 'Element'.",
"startLineNumber": 446,
"startColumn": 48,
"endLineNumber": 446,
"endColumn": 67
},{
"message": "Argument of type 'ChildNode | null' is not assignable to parameter of type 'Node'.\n Type 'null' is not assignable to type 'Node'.",
"startLineNumber": 578,
"startColumn": 43,
"endLineNumber": 578,
"endColumn": 49
},{
"message": "'newElt' is possibly 'null'.",
"startLineNumber": 579,
"startColumn": 17,
"endLineNumber": 579,
"endColumn": 23
},{
"message": "Property 'href' does not exist on type 'ChildNode'.",
"startLineNumber": 579,
"startColumn": 24,
"endLineNumber": 579,
"endColumn": 28
},{
"message": "'newElt' is possibly 'null'.",
"startLineNumber": 579,
"startColumn": 32,
"endLineNumber": 579,
"endColumn": 38
},{
"message": "Property 'src' does not exist on type 'ChildNode'.",
"startLineNumber": 579,
"startColumn": 39,
"endLineNumber": 579,
"endColumn": 42
},{
"message": "Variable 'resolve' implicitly has type 'any' in some locations where its type cannot be determined.",
"startLineNumber": 580,
"startColumn": 21,
"endLineNumber": 580,
"endColumn": 28
},{
"message": "'newElt' is possibly 'null'.",
"startLineNumber": 584,
"startColumn": 17,
"endLineNumber": 584,
"endColumn": 23
},{
"message": "Variable 'resolve' implicitly has an 'any' type.",
"startLineNumber": 585,
"startColumn": 21,
"endLineNumber": 585,
"endColumn": 28
},{
"message": "Argument of type 'ChildNode | null' is not assignable to parameter of type 'Node'.\n Type 'null' is not assignable to type 'Node'.",
"startLineNumber": 589,
"startColumn": 37,
"endLineNumber": 589,
"endColumn": 43
},{
"message": "Argument of type 'ChildNode | null' is not assignable to parameter of type 'Node'.\n Type 'null' is not assignable to type 'Node'.",
"startLineNumber": 590,
"startColumn": 42,
"endLineNumber": 590,
"endColumn": 48
},{
"message": "Argument of type 'ChildNode | null' is not assignable to parameter of type 'Node'.\n Type 'null' is not assignable to type 'Node'.",
"startLineNumber": 591,
"startColumn": 24,
"endLineNumber": 591,
"endColumn": 30
},{
"message": "Type 'Promise<unknown>[]' is not assignable to type 'Promise<void>[]'.\n Type 'Promise<unknown>' is not assignable to type 'Promise<void>'.\n Type 'unknown' is not assignable to type 'void'.",
"startLineNumber": 609,
"startColumn": 5,
"endLineNumber": 609,
"endColumn": 11
},{
"message": "Type 'ChildNode | null' is not assignable to type 'Node'.\n Type 'null' is not assignable to type 'Node'.",
"startLineNumber": 697,
"startColumn": 9,
"endLineNumber": 697,
"endColumn": 23
},{
"message": "Argument of type 'Node' is not assignable to parameter of type 'Element'.\n Type 'Node' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 119 more.",
"startLineNumber": 698,
"startColumn": 20,
"endLineNumber": 698,
"endColumn": 28
},{
"message": "Argument of type 'Node' is not assignable to parameter of type 'Element'.\n Type 'Node' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 119 more.",
"startLineNumber": 740,
"startColumn": 30,
"endLineNumber": 740,
"endColumn": 38
},{
"message": "Type 'ChildNode | null' is not assignable to type 'Node'.\n Type 'null' is not assignable to type 'Node'.",
"startLineNumber": 757,
"startColumn": 13,
"endLineNumber": 757,
"endColumn": 27
},{
"message": "Argument of type 'Node' is not assignable to parameter of type 'Element'.\n Type 'Node' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 119 more.",
"startLineNumber": 790,
"startColumn": 25,
"endLineNumber": 790,
"endColumn": 33
},{
"message": "Argument of type 'Node | null' is not assignable to parameter of type 'Element | null'.\n Type 'Node' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 119 more.",
"startLineNumber": 794,
"startColumn": 25,
"endLineNumber": 794,
"endColumn": 36
},{
"message": "'nextSibling' is possibly 'null'.",
"startLineNumber": 798,
"startColumn": 27,
"endLineNumber": 798,
"endColumn": 38
},{
"message": "Property 'generatedByIdiomorph' does not exist on type 'Document'.",
"startLineNumber": 833,
"startColumn": 21,
"endLineNumber": 833,
"endColumn": 41
},{
"message": "Property 'generatedByIdiomorph' does not exist on type 'ChildNode'.",
"startLineNumber": 839,
"startColumn": 29,
"endLineNumber": 839,
"endColumn": 49
},{
"message": "Object is possibly 'null'.",
"startLineNumber": 852,
"startColumn": 23,
"endLineNumber": 852,
"endColumn": 65
},{
"message": "Property 'generatedByIdiomorph' does not exist on type 'DocumentFragment'.",
"startLineNumber": 853,
"startColumn": 17,
"endLineNumber": 853,
"endColumn": 37
},{
"message": "Property 'generatedByIdiomorph' does not exist on type 'Node | HTMLCollection | Node[] | (Document & { generatedByIdiomorph: boolean; })'.\n Property 'generatedByIdiomorph' does not exist on type 'Node'.",
"startLineNumber": 870,
"startColumn": 27,
"endLineNumber": 870,
"endColumn": 47
},{
"message": "Type 'Node | HTMLCollection | Node[] | (Document & { generatedByIdiomorph: boolean; })' is not assignable to type 'HTMLDivElement'.\n Type 'Node' is missing the following properties from type 'HTMLDivElement': align, accessKey, accessKeyLabel, autocapitalize, and 249 more.",
"startLineNumber": 872,
"startColumn": 9,
"endLineNumber": 872,
"endColumn": 15
},{
"message": "'morphedNode' is possibly 'null'.",
"startLineNumber": 906,
"startColumn": 9,
"endLineNumber": 906,
"endColumn": 20
},{
"message": "'morphedNode.parentElement' is possibly 'null'.",
"startLineNumber": 906,
"startColumn": 9,
"endLineNumber": 906,
"endColumn": 34
},{
"message": "Argument of type 'Node | Element | null' is not assignable to parameter of type 'Node'.\n Type 'null' is not assignable to type 'Node'.",
"startLineNumber": 909,
"startColumn": 16,
"endLineNumber": 909,
"endColumn": 27
},{
"message": "'morphedNode' is possibly 'null'.",
"startLineNumber": 916,
"startColumn": 9,
"endLineNumber": 916,
"endColumn": 20
},{
"message": "'morphedNode.parentElement' is possibly 'null'.",
"startLineNumber": 916,
"startColumn": 9,
"endLineNumber": 916,
"endColumn": 34
},{
"message": "Argument of type 'Node | undefined' is not assignable to parameter of type 'Node'.\n Type 'undefined' is not assignable to type 'Node'.",
"startLineNumber": 917,
"startColumn": 13,
"endLineNumber": 917,
"endColumn": 24
},{
"message": "'morphedNode' is possibly 'null'.",
"startLineNumber": 918,
"startColumn": 13,
"endLineNumber": 918,
"endColumn": 24
},{
"message": "Argument of type 'Node | null' is not assignable to parameter of type 'Element | null'.\n Type 'Node' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 119 more.",
"startLineNumber": 950,
"startColumn": 21,
"endLineNumber": 950,
"endColumn": 26
},{
"message": "Argument of type 'Node | null' is not assignable to parameter of type 'Node'.\n Type 'null' is not assignable to type 'Node'.",
"startLineNumber": 951,
"startColumn": 49,
"endLineNumber": 951,
"endColumn": 54
},{
"message": "'oldParent' is declared but its value is never read.",
"startLineNumber": 773,
"startColumn": 5,
"endLineNumber": 773,
"endColumn": 14,
"tags": [
1
]
}]
type Config = {
morphStyle?: "outerHTML" | "innerHTML";
ignoreActive?: boolean;
ignoreActiveValue?: boolean;
callbacks?: {
beforeNodeAdded?: (node: Node) => boolean;
afterNodeAdded?: (node: Node) => void;
beforeNodeMorphed?: (element: Element, node: Node) => boolean;
afterNodeMorphed?: (element: Element, node: Node) => void;
beforeNodeRemoved?: (element: Element) => boolean;
afterNodeRemoved?: (element: Element) => void;
beforeAttributeUpdated?: (
attribute: string,
element: Element,
action: "update" | "remove",
) => boolean;
};
head?: {
style?: "merge" | "append" | "morph" | "none";
block?: boolean;
ignore?: boolean;
shouldPreserve?: (element: Element) => boolean;
shouldReAppend?: (element: Element) => boolean;
shouldRemove?: (element: Element) => boolean;
afterHeadMorphed?: (
element: Element,
{ added, kept, removed }: {
added: Node[];
kept: Element[];
removed: Element[];
},
) => void;
};
};
type NoOp = () => void;
type ConfigInternal = {
morphStyle: "outerHTML" | "innerHTML";
ignoreActive?: boolean;
ignoreActiveValue?: boolean;
callbacks: {
beforeNodeAdded: (node: Node) => boolean;
afterNodeAdded: (node: Node) => void;
beforeNodeMorphed: (element: Element, node: Node) => boolean;
afterNodeMorphed: (element: Element, node: Node) => void;
beforeNodeRemoved: (element: Element) => boolean;
afterNodeRemoved: (element: Element) => void;
beforeAttributeUpdated: (
attribute: string,
element: Element,
action: "update" | "remove",
) => boolean;
};
head: {
style: "merge" | "append" | "morph" | "none";
block?: boolean;
ignore?: boolean;
shouldPreserve: (element: Element) => boolean;
shouldReAppend: (element: Element) => boolean;
shouldRemove: (element: Element) => boolean;
afterHeadMorphed: (
element: Element,
{ added, kept, removed }: {
added: Node[];
kept: Element[];
removed: Element[];
},
) => void;
};
};
type Morph = (
oldNode: Element | Document,
newContent: Element | Node | HTMLCollection | Node[] | string | null,
config?: Config,
) => undefined | HTMLCollection | Node[];
type MorphContext = {
target: Node;
newContent: Node;
config: ConfigInternal;
morphStyle: ConfigInternal["morphStyle"];
ignoreActive: ConfigInternal["ignoreActive"];
ignoreActiveValue: ConfigInternal["ignoreActiveValue"];
idMap: Map<Node, Set<string>>;
deadIds: Set<string>;
callbacks: ConfigInternal["callbacks"];
head: ConfigInternal["head"];
};
//=============================================================================
// AND NOW IT BEGINS...
//=============================================================================
let EMPTY_SET = new Set<string>();
let defaults: ConfigInternal = {
morphStyle: "outerHTML",
callbacks: {
beforeNodeAdded: noOp,
afterNodeAdded: noOp,
beforeNodeMorphed: noOp,
afterNodeMorphed: noOp,
beforeNodeRemoved: noOp,
afterNodeRemoved: noOp,
beforeAttributeUpdated: noOp,
},
head: {
style: "merge",
shouldPreserve: function (elt) {
return elt.getAttribute("im-preserve") === "true";
},
shouldReAppend: function (elt) {
return elt.getAttribute("im-re-append") === "true";
},
shouldRemove: noOp,
afterHeadMorphed: noOp,
},
};
/**
* =============================================================================
* Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
* =============================================================================
*/
function morph(
oldNode: Element | Document,
newContent: Element | Node | HTMLCollection | Node[] | string | null,
config: Config = {},
): undefined | HTMLCollection | Node[] {
if (oldNode instanceof Document) {
oldNode = oldNode.documentElement;
}
if (typeof newContent === "string") {
newContent = parseContent(newContent);
}
let normalizedContent = normalizeContent(newContent);
let ctx = createMorphContext(oldNode, normalizedContent, config);
return morphNormalizedContent(oldNode, normalizedContent, ctx);
}
function morphNormalizedContent(
oldNode: Element,
normalizedNewContent: HTMLDivElement,
ctx: MorphContext,
): undefined | HTMLCollection | Node[] {
if (ctx.head.block) {
let oldHead = oldNode.querySelector("head");
let newHead = normalizedNewContent.querySelector("head");
if (oldHead && newHead) {
let promises = handleHeadElement(newHead, oldHead, ctx);
// when head promises resolve, call morph again, ignoring the head tag
Promise.all(promises).then(function () {
morphNormalizedContent(
oldNode,
normalizedNewContent,
Object.assign(ctx, {
head: {
block: false,
ignore: true,
},
}),
);
});
return;
}
}
if (ctx.morphStyle === "innerHTML") {
// innerHTML, so we are only updating the children
morphChildren(normalizedNewContent, oldNode, ctx);
return oldNode.children;
} else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
// otherwise find the best element match in the new content, morph that, and merge its siblings
// into either side of the best match
let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
// stash the siblings that will need to be inserted on either side of the best match
let previousSibling = bestMatch?.previousSibling;
let nextSibling = bestMatch?.nextSibling;
// morph it
let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
if (bestMatch) {
// if there was a best match, merge the siblings in too and return the
// whole bunch
return insertSiblings(previousSibling, morphedNode, nextSibling);
} else {
// otherwise nothing was added to the DOM
return [];
}
} else {
throw "Do not understand how to morph style " + ctx.morphStyle;
}
}
// TODO: ignoreActive and ignoreActiveValue are marked as optional since they are not
// initialised in the default config object. As a result the && in the function body may
// return undefined instead of boolean. Either expand the type of the return value to
// include undefined or wrap the ctx.ignoreActiveValue into a Boolean()
function ignoreValueOfActiveElement(
possibleActiveElement: Element,
ctx: MorphContext,
): boolean {
return ctx.ignoreActiveValue &&
possibleActiveElement === document.activeElement &&
possibleActiveElement !== document.body;
}
function morphOldNodeTo(
oldNode: Element,
newContent: Node | null,
ctx: MorphContext,
): Element | Node | null | undefined {
if (ctx.ignoreActive && oldNode === document.activeElement) {
// don't morph focused element
} else if (newContent == null) {
if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
oldNode.remove();
ctx.callbacks.afterNodeRemoved(oldNode);
return null;
} else if (!isSoftMatch(oldNode, newContent)) {
if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode;
oldNode.parentElement.replaceChild(newContent, oldNode);
ctx.callbacks.afterNodeAdded(newContent);
ctx.callbacks.afterNodeRemoved(oldNode);
return newContent;
} else {
if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) {
return oldNode;
}
if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) {
// ignore the head element
} else if (
oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph"
) {
handleHeadElement(newContent, oldNode, ctx);
} else {
syncNodeFrom(newContent, oldNode, ctx);
if (!ignoreValueOfActiveElement(oldNode, ctx)) {
morphChildren(newContent, oldNode, ctx);
}
}
ctx.callbacks.afterNodeMorphed(oldNode, newContent);
return oldNode;
}
}
/**
* This is the core algorithm for matching up children. The idea is to use id sets to try to match up
* nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
* by using id sets, we are able to better match up with content deeper in the DOM.
*
* Basic algorithm is, for each node in the new content:
*
* - if we have reached the end of the old parent, append the new content
* - if the new content has an id set match with the current insertion point, morph
* - search for an id set match
* - if id set match found, morph
* - otherwise search for a "soft" match
* - if a soft match is found, morph
* - otherwise, prepend the new node before the current insertion point
*
* The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
* with the current node. See findIdSetMatch() and findSoftMatch() for details.
*/
function morphChildren(newParent: Node, oldParent: Element, ctx: MorphContext) {
if (
newParent instanceof HTMLTemplateElement &&
oldParent instanceof HTMLTemplateElement
) {
newParent = newParent.content;
oldParent = oldParent.content;
}
let nextNewChild: Node | null = newParent.firstChild;
let insertionPoint: Node | null = oldParent.firstChild;
let newChild;
// run through all the new content
while (nextNewChild) {
newChild = nextNewChild;
nextNewChild = newChild.nextSibling;
// if we are at the end of the exiting parent's children, just append
if (insertionPoint == null) {
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
oldParent.appendChild(newChild);
ctx.callbacks.afterNodeAdded(newChild);
removeIdsFromConsideration(ctx, newChild);
continue;
}
// if the current node has an id set match then morph
if (isIdSetMatch(newChild, insertionPoint, ctx)) {
morphOldNodeTo(insertionPoint, newChild, ctx);
insertionPoint = insertionPoint.nextSibling;
removeIdsFromConsideration(ctx, newChild);
continue;
}
// otherwise search forward in the existing old children for an id set match
let idSetMatch = findIdSetMatch(
newParent,
oldParent,
newChild,
insertionPoint,
ctx,
);
// if we found a potential match, remove the nodes until that point and morph
if (idSetMatch) {
insertionPoint = removeNodesBetween(
insertionPoint,
idSetMatch,
ctx,
);
morphOldNodeTo(idSetMatch, newChild, ctx);
removeIdsFromConsideration(ctx, newChild);
continue;
}
// no id set match found, so scan forward for a soft match for the current node
let softMatch = findSoftMatch(
newParent,
oldParent,
newChild,
insertionPoint,
ctx,
);
// if we found a soft match for the current node, morph
if (softMatch) {
insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
morphOldNodeTo(softMatch, newChild, ctx);
removeIdsFromConsideration(ctx, newChild);
continue;
}
// abandon all hope of morphing, just insert the new child before the insertion point
// and move on
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
oldParent.insertBefore(newChild, insertionPoint);
ctx.callbacks.afterNodeAdded(newChild);
removeIdsFromConsideration(ctx, newChild);
}
// remove any remaining old nodes that didn't match up with new content
while (insertionPoint !== null) {
let tempNode = insertionPoint;
insertionPoint = insertionPoint.nextSibling;
removeNode(tempNode, ctx);
}
}
//=============================================================================
// Attribute Syncing Code
//=============================================================================
function ignoreAttribute(
attr: string,
to: Element,
updateType: "update" | "remove",
ctx: MorphContext,
): boolean {
if (
attr === "value" && ctx.ignoreActiveValue &&
to === document.activeElement
) {
return true;
}
return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false;
}
/**
* syncs a given node with another node, copying over all attributes and
* inner element state from the 'from' node to the 'to' node
*/
function syncNodeFrom(from: Element, to: Element, ctx: MorphContext) {
let type = from.nodeType;
// if is an element type, sync the attributes from the
// new node into the new node
if (type === 1 /* element type */) {
const fromAttributes = from.attributes;
const toAttributes = to.attributes;
for (const fromAttribute of fromAttributes) {
if (ignoreAttribute(fromAttribute.name, to, "update", ctx)) {
continue;
}
if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
to.setAttribute(fromAttribute.name, fromAttribute.value);
}
}
// iterate backwards to avoid skipping over items when a delete occurs
for (let i = toAttributes.length - 1; 0 <= i; i--) {
const toAttribute = toAttributes[i];
if (ignoreAttribute(toAttribute.name, to, "remove", ctx)) {
continue;
}
if (!from.hasAttribute(toAttribute.name)) {
to.removeAttribute(toAttribute.name);
}
}
}
// sync text nodes
if (type === 8 /* comment */ || type === 3 /* text */) {
if (to.nodeValue !== from.nodeValue) {
to.nodeValue = from.nodeValue;
}
}
if (!ignoreValueOfActiveElement(to, ctx)) {
// sync input values
syncInputValue(from, to, ctx);
}
}
function syncBooleanAttribute(
from: Element,
to: Element,
attributeName: string,
ctx: MorphContext,
) {
// TODO: prefer set/getAttribute here
if (from[attributeName] !== to[attributeName]) {
let ignoreUpdate = ignoreAttribute(attributeName, to, "update", ctx);
if (!ignoreUpdate) {
to[attributeName] = from[attributeName];
}
if (from[attributeName]) {
if (!ignoreUpdate) {
to.setAttribute(attributeName, from[attributeName]);
}
} else {
if (!ignoreAttribute(attributeName, to, "remove", ctx)) {
to.removeAttribute(attributeName);
}
}
}
}
/**
* NB: many bothans died to bring us information:
*
* https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
* https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
*/
function syncInputValue(from: Element, to: Element, ctx: MorphContext) {
if (
from instanceof HTMLInputElement &&
to instanceof HTMLInputElement &&
from.type !== "file"
) {
let fromValue = from.value;
let toValue = to.value;
// sync boolean attributes
syncBooleanAttribute(from, to, "checked", ctx);
syncBooleanAttribute(from, to, "disabled", ctx);
if (!from.hasAttribute("value")) {
if (!ignoreAttribute("value", to, "remove", ctx)) {
to.value = "";
to.removeAttribute("value");
}
} else if (fromValue !== toValue) {
if (!ignoreAttribute("value", to, "update", ctx)) {
to.setAttribute("value", fromValue);
to.value = fromValue;
}
}
} else if (from instanceof HTMLOptionElement) {
syncBooleanAttribute(from, to, "selected", ctx);
} else if (
from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement
) {
let fromValue = from.value;
let toValue = to.value;
if (ignoreAttribute("value", to, "update", ctx)) {
return;
}
if (fromValue !== toValue) {
to.value = fromValue;
}
if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
to.firstChild.nodeValue = fromValue;
}
}
}
/**
* =============================================================================
* The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
* =============================================================================
*/
function handleHeadElement(
newHeadTag: Element,
currentHead: Element,
ctx: MorphContext,
): Promise<void>[] {
/**
* @type {Node[]}
*/
const added = new Array<Node>();
const removed = new Array<Element>();
const preserved = new Array<Element>();
const nodesToAppend = new Array<Element>();
let headMergeStyle = ctx.head.style;
// put all new head elements into a Map, by their outerHTML
let srcToNewHeadNodes = new Map();
for (const newHeadChild of newHeadTag.children) {
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
}
// for each elt in the current head
for (const currentHeadElt of currentHead.children) {
// If the current head element is in the map
let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
if (inNewContent || isPreserved) {
if (isReAppended) {
// remove the current version and let the new version replace it and re-execute
removed.push(currentHeadElt);
} else {
// this element already exists and should not be re-appended, so remove it from
// the new content map, preserving it in the DOM
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
preserved.push(currentHeadElt);
}
} else {
if (headMergeStyle === "append") {
// we are appending and this existing element is not new content
// so if and only if it is marked for re-append do we do anything
if (isReAppended) {
removed.push(currentHeadElt);
nodesToAppend.push(currentHeadElt);
}
} else {
// if this is a merge, we remove this content since it is not in the new head
if (ctx.head.shouldRemove(currentHeadElt) !== false) {
removed.push(currentHeadElt);
}
}
}
}
// Push the remaining new head elements in the Map into the
// nodes to append to the head tag
nodesToAppend.push(...srcToNewHeadNodes.values());
// console.log("to append: ", nodesToAppend);
let promises = [];
for (const newNode of nodesToAppend) {
// console.log("adding: ", newNode);
// TODO: This could theoretically be null, based on type
let newElt =
document.createRange().createContextualFragment(newNode.outerHTML)
.firstChild;
// console.log(newElt);
if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
if (newElt.href || newElt.src) {
let resolve = null;
let promise = new Promise(function (_resolve) {
resolve = _resolve;
});
newElt.addEventListener("load", function () {
resolve();
});
promises.push(promise);
}
currentHead.appendChild(newElt);
ctx.callbacks.afterNodeAdded(newElt);
added.push(newElt);
}
}
// remove all removed elements, after we have appended the new elements to avoid
// additional network requests for things like style sheets
for (const removedElement of removed) {
if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
currentHead.removeChild(removedElement);
ctx.callbacks.afterNodeRemoved(removedElement);
}
}
ctx.head.afterHeadMorphed(currentHead, {
added: added,
kept: preserved,
removed: removed,
});
return promises;
}
//=============================================================================
// Misc
//=============================================================================
function noOp() {
return false;
}
/**
* Deep merges the config object and the Idiomoroph.defaults object to
* produce a final configuration object
*/
function mergeDefaults(config: Config) {
const finalConfig: ConfigInternal = Object.assign({}, defaults);
// copy top level stuff into final config
Object.assign(finalConfig, config);
// copy callbacks into final config (do this to deep merge the callbacks)
Object.assign(finalConfig.callbacks, config.callbacks);
// copy head config into final config (do this to deep merge the head)
Object.assign(finalConfig.head, config.head);
return finalConfig;
}
function createMorphContext(
oldNode: Element,
newContent: HTMLDivElement,
config: Config,
): MorphContext {
const mergedConfig = mergeDefaults(config);
return {
target: oldNode,
newContent: newContent,
config: mergedConfig,
morphStyle: mergedConfig.morphStyle,
ignoreActive: mergedConfig.ignoreActive,
ignoreActiveValue: mergedConfig.ignoreActiveValue,
idMap: createIdMap(oldNode, newContent),
deadIds: new Set(),
callbacks: mergedConfig.callbacks,
head: mergedConfig.head,
};
}
// TODO: The function handles this as if it's Element or null, but the function is called in
// places where the arguments may be just a Node, not an Element
function isIdSetMatch(
node1: Element | null,
node2: Element | null,
ctx: MorphContext,
): boolean {
if (node1 == null || node2 == null) {
return false;
}
if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
if (node1.id !== "" && node1.id === node2.id) {
return true;
} else {
return getIdIntersectionCount(ctx, node1, node2) > 0;
}
}
return false;
}
// TODO: The function handles this as if it's Element or null, but the function is called in
// places where the arguments may be just a Node, not an Element
function isSoftMatch(node1: Element | null, node2: Element | null): boolean {
if (node1 == null || node2 == null) {
return false;
}
return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName;
}
function removeNodesBetween(
startInclusive: Node,
endExclusive: Node,
ctx: MorphContext,
): Node | null {
while (startInclusive !== endExclusive) {
let tempNode = startInclusive;
// TODO: Prefer assigning to a new variable here or expand the type of startInclusive
// to be Node | null
startInclusive = startInclusive.nextSibling;
removeNode(tempNode, ctx);
}
removeIdsFromConsideration(ctx, endExclusive);
return endExclusive.nextSibling;
}
/**
* =============================================================================
* Scans forward from the insertionPoint in the old parent looking for a potential id match
* for the newChild. We stop if we find a potential id match for the new child OR
* if the number of potential id matches we are discarding is greater than the
* potential id matches for the new child
* =============================================================================
*/
function findIdSetMatch(
newContent: Node,
oldParent: Element,
newChild: Node,
insertionPoint: Node,
ctx: MorphContext,
): Node | null {
// max id matches we are willing to discard in our search
let newChildPotentialIdCount = getIdIntersectionCount(
ctx,
newChild,
oldParent,
);
let potentialMatch: Node | null = null;
// only search forward if there is a possibility of an id match
if (newChildPotentialIdCount > 0) {
// TODO: This is ghosting the potentialMatch variable outside of this block.
// Probably an error
let potentialMatch = insertionPoint;
// if there is a possibility of an id match, scan forward
// keep track of the potential id match count we are discarding (the
// newChildPotentialIdCount must be greater than this to make it likely
// worth it)
let otherMatchCount = 0;
while (potentialMatch != null) {
// If we have an id match, return the current potential match
if (isIdSetMatch(newChild, potentialMatch, ctx)) {
return potentialMatch;
}
// computer the other potential matches of this new content
otherMatchCount += getIdIntersectionCount(
ctx,
potentialMatch,
newContent,
);
if (otherMatchCount > newChildPotentialIdCount) {
// if we have more potential id matches in _other_ content, we
// do not have a good candidate for an id match, so return null
return null;
}
// advanced to the next old content child
potentialMatch = potentialMatch.nextSibling;
}
}
return potentialMatch;
}
/**
* =============================================================================
* Scans forward from the insertionPoint in the old parent looking for a potential soft match
* for the newChild. We stop if we find a potential soft match for the new child OR
* if we find a potential id match in the old parents children OR if we find two
* potential soft matches for the next two pieces of new content
* =============================================================================
*/
function findSoftMatch(
newContent: Node,
oldParent: Element,
newChild: Node,
insertionPoint: Node,
ctx: MorphContext,
): Node | null {
let potentialSoftMatch: Node | null = insertionPoint;
let nextSibling: Node | null = newChild.nextSibling;
let siblingSoftMatchCount = 0;
while (potentialSoftMatch != null) {
if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
// the current potential soft match has a potential id set match with the remaining new
// content so bail out of looking
return null;
}
// if we have a soft match with the current node, return it
if (isSoftMatch(newChild, potentialSoftMatch)) {
return potentialSoftMatch;
}
if (isSoftMatch(nextSibling, potentialSoftMatch)) {
// the next new node has a soft match with this node, so
// increment the count of future soft matches
siblingSoftMatchCount++;
nextSibling = nextSibling.nextSibling;
// If there are two future soft matches, bail to allow the siblings to soft match
// so that we don't consume future soft matches for the sake of the current node
if (siblingSoftMatchCount >= 2) {
return null;
}
}
// advanced to the next old content child
potentialSoftMatch = potentialSoftMatch.nextSibling;
}
return potentialSoftMatch;
}
function parseContent(newContent: string): Node | null | DocumentFragment {
let parser = new DOMParser();
// remove svgs to avoid false-positive matches on head, etc.
let contentWithSvgsRemoved = newContent.replace(
/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim,
"",
);
// if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
if (
contentWithSvgsRemoved.match(/<\/html>/) ||
contentWithSvgsRemoved.match(/<\/head>/) ||
contentWithSvgsRemoved.match(/<\/body>/)
) {
let content = parser.parseFromString(newContent, "text/html");
// if it is a full HTML document, return the document itself as the parent container
if (contentWithSvgsRemoved.match(/<\/html>/)) {
// TODO: prefer using set/getAttribute
content.generatedByIdiomorph = true;
return content;
} else {
// otherwise return the html element as the parent container
let htmlElement = content.firstChild;
if (htmlElement) {
htmlElement.generatedByIdiomorph = true;
return htmlElement;
} else {
return null;
}
}
} else {
// if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
// deal with touchy tags like tr, tbody, etc.
let responseDoc = parser.parseFromString(
"<body><template>" + newContent + "</template></body>",
"text/html",
);
let content = responseDoc.body.querySelector("template").content;
content.generatedByIdiomorph = true;
return content;
}
}
function normalizeContent(
newContent:
| null
| Node
| HTMLCollection
| Node[]
| Document & { generatedByIdiomorph: boolean },
): HTMLDivElement {
if (newContent == null) {
// noinspection UnnecessaryLocalVariableJS
const dummyParent = document.createElement("div");
return dummyParent;
} else if (newContent.generatedByIdiomorph) {
// the template tag created by idiomorph parsing can serve as a dummy parent
return newContent;
} else if (newContent instanceof Node) {
// a single node is added as a child to a dummy parent
const dummyParent = document.createElement("div");
dummyParent.append(newContent);
return dummyParent;
} else {
// all nodes in the array or HTMLElement collection are consolidated under
// a single dummy parent element
const dummyParent = document.createElement("div");
for (const elt of [...newContent]) {
dummyParent.append(elt);
}
return dummyParent;
}
}
function insertSiblings(
previousSibling: Node | null | undefined,
morphedNode: Element | Node | null,
nextSibling: Node | null,
): Node[] {
const stack = new Array<Node>();
const added = new Array<Node>();
while (previousSibling != null) {
stack.push(previousSibling);
previousSibling = previousSibling.previousSibling;
}
// Base the loop on the node variable, so that you do not need runtime checks for
// undefined value inside the loop
let node = stack.pop();
while (node !== undefined) {
added.push(node); // push added preceding siblings on in order and insert
morphedNode.parentElement.insertBefore(node, morphedNode);
node = stack.pop();
}
added.push(morphedNode);
while (nextSibling != null) {
stack.push(nextSibling);
added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
nextSibling = nextSibling.nextSibling;
}
while (stack.length > 0) {
morphedNode.parentElement.insertBefore(
stack.pop(),
morphedNode.nextSibling,
);
}
return added;
}
function findBestNodeMatch(
newContent: HTMLDivElement,
oldNode: Element,
ctx: MorphContext,
): Node | null {
let currentElement: Node | null = newContent.firstChild;
let bestElement: Node | null = currentElement;
let score = 0;
while (currentElement) {
let newScore = scoreElement(currentElement, oldNode, ctx);
if (newScore > score) {
bestElement = currentElement;
score = newScore;
}
currentElement = currentElement.nextSibling;
}
return bestElement;
}
// TODO: The function handles node1 and node2 as if they are Elements but the function is
// called in places where node1 and node2 may be just Nodes, not Elements
function scoreElement(
node1: Node | null,
node2: Element,
ctx: MorphContext,
): number {
if (isSoftMatch(node1, node2)) {
return .5 + getIdIntersectionCount(ctx, node1, node2);
}
return 0;
}
// TODO: The function handles tempNode as if it's Element but the function is called in
// places where tempNode may be just a Node, not an Element
function removeNode(tempNode: Element, ctx: MorphContext) {
removeIdsFromConsideration(ctx, tempNode);
if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
tempNode.remove();
ctx.callbacks.afterNodeRemoved(tempNode);
}
//=============================================================================
// ID Set Functions
//=============================================================================
function isIdInConsideration(ctx: MorphContext, id: string): boolean {
return !ctx.deadIds.has(id);
}
function idIsWithinNode(
ctx: MorphContext,
id: string,
targetNode: Node,
): boolean {
let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
return idSet.has(id);
}
function removeIdsFromConsideration(ctx: MorphContext, node: Node) {
let idSet = ctx.idMap.get(node) || EMPTY_SET;
for (const id of idSet) {
ctx.deadIds.add(id);
}
}
function getIdIntersectionCount(
ctx: MorphContext,
node1: Node,
node2: Node,
): number {
let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
let matchCount = 0;
for (const id of sourceSet) {
// a potential match is an id in the source and potentialIdsSet, but
// that has not already been merged into the DOM
if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
++matchCount;
}
}
return matchCount;
}
/**
* A bottom up algorithm that finds all elements with ids inside of the node
* argument and populates id sets for those nodes and all their parents, generating
* a set of ids contained within all nodes for the entire hierarchy in the DOM
*/
function populateIdMapForNode(node: Element, idMap: Map<Node, Set<string>>) {
let nodeParent = node.parentElement;
// find all elements with an id property
let idElements = node.querySelectorAll("[id]");
for (const elt of idElements) {
let current: Element | null = elt;
// walk up the parent hierarchy of that element, adding the id
// of element to the parent's id set
while (current !== nodeParent && current != null) {
let idSet = idMap.get(current);
// if the id set doesn't exist, create it and insert it in the map
if (idSet == null) {
idSet = new Set();
idMap.set(current, idSet);
}
idSet.add(elt.id);
current = current.parentElement;
}
}
}
/**
* This function computes a map of nodes to all ids contained within that node (inclusive of the
* node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
* for a looser definition of "matching" than tradition id matching, and allows child nodes
* to contribute to a parent nodes matching.
*/
function createIdMap(
oldContent: Element,
newContent: Element,
): Map<Node, Set<string>> {
const idMap = new Map<Node, Set<string>>();
populateIdMapForNode(oldContent, idMap);
populateIdMapForNode(newContent, idMap);
return idMap;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment