···11-type HTMLOrSVG = HTMLElement | SVGElement | MathMLElement
22-33-export const isHTMLOrSVG = (el: Node): el is HTMLOrSVG =>
44- el instanceof HTMLElement || el instanceof SVGElement || el instanceof MathMLElement
55-66-const ctxIdMap = new Map<Node, Set<string>>()
77-const ctxPersistentIds = new Set<string>()
88-const oldIdTagNameMap = new Map<string, string>()
99-const duplicateIds = new Set<string>()
1010-const ctxPantry = document.createElement("div")
1111-ctxPantry.hidden = true
1212-1313-const aliasedIgnoreMorph = "ignore-morph"
1414-const aliasedIgnoreMorphAttr = `[${aliasedIgnoreMorph}]`
1515-export const morph = (
1616- oldElt: Element | ShadowRoot,
1717- newContent: DocumentFragment | Element,
1818- mode: "outer" | "inner" = "outer",
1919-): void => {
2020- if (
2121- (isHTMLOrSVG(oldElt) &&
2222- isHTMLOrSVG(newContent) &&
2323- oldElt.hasAttribute(aliasedIgnoreMorph) &&
2424- newContent.hasAttribute(aliasedIgnoreMorph)) ||
2525- oldElt.parentElement?.closest(aliasedIgnoreMorphAttr)
2626- ) {
2727- return
2828- }
2929-3030- const normalizedElt = document.createElement("div")
3131- normalizedElt.append(newContent)
3232- document.body.insertAdjacentElement("afterend", ctxPantry)
3333-3434- // Computes the set of IDs that persist between the two contents excluding duplicates
3535- const oldIdElements = oldElt.querySelectorAll("[id]")
3636- for (const { id, tagName } of oldIdElements) {
3737- if (oldIdTagNameMap.has(id)) {
3838- duplicateIds.add(id)
3939- } else {
4040- oldIdTagNameMap.set(id, tagName)
4141- }
4242- }
4343- if (oldElt instanceof Element && oldElt.id) {
4444- if (oldIdTagNameMap.has(oldElt.id)) {
4545- duplicateIds.add(oldElt.id)
4646- } else {
4747- oldIdTagNameMap.set(oldElt.id, oldElt.tagName)
4848- }
4949- }
5050-5151- ctxPersistentIds.clear()
5252- const newIdElements = normalizedElt.querySelectorAll("[id]")
5353- for (const { id, tagName } of newIdElements) {
5454- if (ctxPersistentIds.has(id)) {
5555- duplicateIds.add(id)
5656- } else if (oldIdTagNameMap.get(id) === tagName) {
5757- ctxPersistentIds.add(id)
5858- }
5959- }
6060-6161- for (const id of duplicateIds) {
6262- ctxPersistentIds.delete(id)
6363- }
6464-6565- oldIdTagNameMap.clear()
6666- duplicateIds.clear()
6767- ctxIdMap.clear()
6868-6969- const parent = mode === "outer" ? oldElt.parentElement! : oldElt
7070- populateIdMapWithTree(parent, oldIdElements)
7171- populateIdMapWithTree(normalizedElt, newIdElements)
7272-7373- morphChildren(parent, normalizedElt, mode === "outer" ? oldElt : null, oldElt.nextSibling)
7474-7575- ctxPantry.remove()
7676-}
7777-7878-// This is the core algorithm for matching up children.
7979-// The idea is to use ID sets to try to match up nodes as faithfully as possible.
8080-// We greedily match, which allows us to keep the algorithm fast,
8181-// but by using ID sets, we are able to better match up with content deeper in the DOM.
8282-const morphChildren = (
8383- oldParent: Element | ShadowRoot, // the old content that we are merging the new content into
8484- newParent: Element, // the parent element of the new content
8585- insertionPoint: Node | null = null, // the point in the DOM we start morphing at (defaults to first child)
8686- endPoint: Node | null = null, // the point in the DOM we stop morphing at (defaults to after last child)
8787-): void => {
8888- // normalize
8989- if (oldParent instanceof HTMLTemplateElement && newParent instanceof HTMLTemplateElement) {
9090- // we can pretend the DocumentElement is an Element
9191- oldParent = oldParent.content as unknown as Element
9292- newParent = newParent.content as unknown as Element
9393- }
9494- insertionPoint ??= oldParent.firstChild
9595-9696- // run through all the new content
9797- for (const newChild of newParent.childNodes) {
9898- // once we reach the end of the old parent content skip to the end and insert the rest
9999- if (insertionPoint && insertionPoint !== endPoint) {
100100- const bestMatch = findBestMatch(newChild, insertionPoint, endPoint)
101101- if (bestMatch) {
102102- // if the node to morph is not at the insertion point then remove/move up to it
103103- if (bestMatch !== insertionPoint) {
104104- let cursor: Node | null = insertionPoint
105105- // Remove nodes between the start and end nodes
106106- while (cursor && cursor !== bestMatch) {
107107- const tempNode = cursor
108108- cursor = cursor.nextSibling
109109- removeNode(tempNode)
110110- }
111111- }
112112- morphNode(bestMatch, newChild)
113113- insertionPoint = bestMatch.nextSibling
114114- continue
115115- }
116116- }
117117-118118- // if the matching node is elsewhere in the original content
119119- if (newChild instanceof Element && ctxPersistentIds.has(newChild.id)) {
120120- // move it and all its children here and morph, will always be found
121121- // Search for an element by ID within the document and pantry, and move it using moveBefore.
122122- const movedChild = document.getElementById(newChild.id) as Element
123123-124124- // Removes an element from its ancestors' ID maps.
125125- // This is needed when an element is moved from the "future" via `moveBeforeId`.
126126- // Otherwise, its erstwhile ancestors could be mistakenly moved to the pantry rather than being deleted,
127127- // preventing their removal hooks from being called.
128128- let current = movedChild
129129- while ((current = current.parentNode as Element)) {
130130- const idSet = ctxIdMap.get(current)
131131- if (idSet) {
132132- idSet.delete(newChild.id)
133133- if (!idSet.size) {
134134- ctxIdMap.delete(current)
135135- }
136136- }
137137- }
138138-139139- moveBefore(oldParent, movedChild, insertionPoint)
140140- morphNode(movedChild, newChild)
141141- insertionPoint = movedChild.nextSibling
142142- continue
143143- }
144144-145145- // This performs the action of inserting a new node while handling situations where the node contains
146146- // elements with persistent IDs and possible state info we can still preserve by moving in and then morphing
147147- if (ctxIdMap.has(newChild)) {
148148- // node has children with IDs with possible state so create a dummy elt of same type and apply full morph algorithm
149149- const newEmptyChild = document.createElement((newChild as Element).tagName)
150150- oldParent.insertBefore(newEmptyChild, insertionPoint)
151151- morphNode(newEmptyChild, newChild)
152152- insertionPoint = newEmptyChild.nextSibling
153153- } else {
154154- // optimization: no id state to preserve so we can just insert a clone of the newChild and its descendants
155155- const newClonedChild = document.importNode(newChild, true) // importNode to not mutate newParent
156156- oldParent.insertBefore(newClonedChild, insertionPoint)
157157- insertionPoint = newClonedChild.nextSibling
158158- }
159159- }
160160-161161- // remove any remaining old nodes that didn't match up with new content
162162- while (insertionPoint && insertionPoint !== endPoint) {
163163- const tempNode = insertionPoint
164164- insertionPoint = insertionPoint.nextSibling
165165- removeNode(tempNode)
166166- }
167167-}
168168-169169-// Scans forward from the startPoint to the endPoint looking for a match for the node.
170170-// It looks for an id set match first, then a soft match.
171171-// We abort soft matching if we find two future soft matches, to reduce churn.
172172-const findBestMatch = (node: Node, startPoint: Node | null, endPoint: Node | null): Node | null => {
173173- let bestMatch: Node | null | undefined = null
174174- let nextSibling = node.nextSibling
175175- let siblingSoftMatchCount = 0
176176- let displaceMatchCount = 0
177177-178178- // Max ID matches we are willing to displace in our search
179179- const nodeMatchCount = ctxIdMap.get(node)?.size || 0
180180-181181- let cursor = startPoint
182182- while (cursor && cursor !== endPoint) {
183183- // soft matching is a prerequisite for id set matching
184184- if (isSoftMatch(cursor, node)) {
185185- let isIdSetMatch = false
186186- const oldSet = ctxIdMap.get(cursor)
187187- const newSet = ctxIdMap.get(node)
188188-189189- if (newSet && oldSet) {
190190- for (const id of oldSet) {
191191- // a potential match is an id in the new and old nodes that
192192- // has not already been merged into the DOM
193193- // But the newNode content we call this on has not been
194194- // merged yet and we don't allow duplicate IDs so it is simple
195195- if (newSet.has(id)) {
196196- isIdSetMatch = true
197197- break
198198- }
199199- }
200200- }
201201-202202- if (isIdSetMatch) {
203203- return cursor // found an id set match, we're done!
204204- }
205205-206206- // we haven’t yet saved a soft match fallback
207207- // the current soft match will hard match something else in the future, leave it
208208- if (!bestMatch && !ctxIdMap.has(cursor)) {
209209- // optimization: if node can't id set match, we can just return the soft match immediately
210210- if (!nodeMatchCount) {
211211- return cursor
212212- }
213213- // save this as the fallback if we get through the loop without finding a hard match
214214- bestMatch = cursor
215215- }
216216- }
217217-218218- // check for IDs we may be displaced when matching
219219- displaceMatchCount += ctxIdMap.get(cursor)?.size || 0
220220- if (displaceMatchCount > nodeMatchCount) {
221221- // if we are going to displace more IDs than the node contains then
222222- // we do not have a good candidate for an ID match, so return
223223- break
224224- }
225225-226226- if (bestMatch === null && nextSibling && isSoftMatch(cursor, nextSibling)) {
227227- // The next new node has a soft match with this node, so
228228- // increment the count of future soft matches
229229- siblingSoftMatchCount++
230230- nextSibling = nextSibling.nextSibling
231231-232232- // If there are two future soft matches, block soft matching for this node to allow
233233- // future siblings to soft match. This is to reduce churn in the DOM when an element
234234- // is prepended.
235235- if (siblingSoftMatchCount >= 2) {
236236- bestMatch = undefined
237237- }
238238- }
239239-240240- cursor = cursor.nextSibling
241241- }
242242-243243- return bestMatch || null
244244-}
245245-246246-// ok to cast: if one is not element, `id` and `tagName` will be null and we'll just compare that.
247247-const isSoftMatch = (oldNode: Node, newNode: Node): boolean =>
248248- oldNode.nodeType === newNode.nodeType &&
249249- (oldNode as Element).tagName === (newNode as Element).tagName &&
250250- // If oldElt has an `id` with possible state and it doesn’t match newElt.id then avoid morphing.
251251- // We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
252252- // its not persistent, and new nodes can't have any hidden state.
253253- (!(oldNode as Element).id || (oldNode as Element).id === (newNode as Element).id)
254254-255255-// Gets rid of an unwanted DOM node; strategy depends on nature of its reuse:
256256-// - Persistent nodes will be moved to the pantry for later reuse
257257-// - Other nodes will have their hooks called, and then are removed
258258-const removeNode = (node: Node): void => {
259259- // are we going to id set match this later?
260260- ctxIdMap.has(node)
261261- ? // skip callbacks and move to pantry
262262- moveBefore(ctxPantry, node, null)
263263- : // remove for realsies
264264- node.parentNode?.removeChild(node)
265265-}
266266-267267-// Moves an element before another element within the same parent.
268268-// Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`.
269269-// This is essentially a forward-compat wrapper.
270270-const moveBefore: (parentNode: Node, node: Node, after: Node | null) => void =
271271- // @ts-expect-error
272272- removeNode.call.bind(ctxPantry.moveBefore ?? ctxPantry.insertBefore)
273273-274274-const aliasedPreserveAttr = "preserve-attr"
275275-276276-// syncs the oldNode to the newNode, copying over all attributes and
277277-// inner element state from the newNode to the oldNode
278278-const morphNode = (
279279- oldNode: Node, // root node to merge content into
280280- newNode: Node, // new content to merge
281281-): Node => {
282282- const type = newNode.nodeType
283283-284284- // if is an element type, sync the attributes from the
285285- // new node into the new node
286286- if (type === 1 /* element type */) {
287287- const oldElt = oldNode as Element
288288- const newElt = newNode as Element
289289- if (oldElt.hasAttribute(aliasedIgnoreMorph) && newElt.hasAttribute(aliasedIgnoreMorph)) {
290290- return oldNode
291291- }
292292-293293- // many bothans died to bring us this information:
294294- // https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
295295- // https://github.com/choojs/nanomorph/blob/master/lib/morph.js#L113
296296- if (oldElt instanceof HTMLInputElement && newElt instanceof HTMLInputElement && newElt.type !== "file") {
297297- // https://github.com/bigskysoftware/idiomorph/issues/27
298298- // | old input value | new input value | behaviour |
299299- // | --------------- | ---------------- | -------------------------------------- |
300300- // | `null` | `null` | preserve old input value |
301301- // | some value | the same value | preserve old input value |
302302- // | some value | `null` | set old input value to `""` |
303303- // | `null` | some value | set old input value to new input value |
304304- // | some value | some other value | set old input value to new input value |
305305- if (newElt.getAttribute("value") !== oldElt.getAttribute("value")) {
306306- oldElt.value = newElt.getAttribute("value") ?? ""
307307- }
308308- } else if (oldElt instanceof HTMLTextAreaElement && newElt instanceof HTMLTextAreaElement) {
309309- if (newElt.value !== oldElt.value) {
310310- oldElt.value = newElt.value
311311- }
312312- if (oldElt.firstChild && oldElt.firstChild.nodeValue !== newElt.value) {
313313- oldElt.firstChild.nodeValue = newElt.value
314314- }
315315- }
316316-317317- const preserveAttrs = ((newNode as HTMLElement).getAttribute(aliasedPreserveAttr) ?? "").split(" ")
318318-319319- for (const { name, value } of newElt.attributes) {
320320- if (oldElt.getAttribute(name) !== value && !preserveAttrs.includes(name)) {
321321- oldElt.setAttribute(name, value)
322322- }
323323- }
324324-325325- for (let i = oldElt.attributes.length - 1; i >= 0; i--) {
326326- const { name } = oldElt.attributes[i]!
327327- if (!newElt.hasAttribute(name) && !preserveAttrs.includes(name)) {
328328- oldElt.removeAttribute(name)
329329- }
330330- }
331331-332332- if (!oldElt.isEqualNode(newElt)) {
333333- morphChildren(oldElt, newElt)
334334- }
335335- }
336336-337337- if (type === 8 /* comment */ || type === 3 /* text */) {
338338- if (oldNode.nodeValue !== newNode.nodeValue) {
339339- oldNode.nodeValue = newNode.nodeValue
340340- }
341341- }
342342-343343- return oldNode
344344-}
345345-346346-// A bottom-up algorithm that populates a map of Element -> IdSet.
347347-// The ID set for a given element is the set of all IDs contained within its subtree.
348348-// As an optimization, we filter these IDs through the given list of persistent IDs,
349349-// because we don't need to bother considering IDed elements that won't be in the new content.
350350-const populateIdMapWithTree = (root: Element | ShadowRoot | null, elements: Iterable<Element>): void => {
351351- for (const elt of elements) {
352352- if (ctxPersistentIds.has(elt.id)) {
353353- let current: Element | null = elt
354354- // walk up the parent hierarchy of that element, adding the ID of element to the parent's ID set
355355- while (current && current !== root) {
356356- let idSet = ctxIdMap.get(current)
357357- // if the ID set doesn’t exist, create it and insert it in the map
358358- if (!idSet) {
359359- idSet = new Set()
360360- ctxIdMap.set(current, idSet)
361361- }
362362- idSet.add(elt.id)
363363- current = current.parentElement
364364- }
365365- }
366366- }
367367-}