diff --git a/AGENTS.md b/AGENTS.md index d290d77..3fa7597 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ A live target that's in the page is inserted with `moveBefore` too (when it's in ### Identical siblings -The `isEqualNode` pass gives each target the first free equal element, so when an element changes and a sibling is identical to its old version, the unchanged target takes the changed element's live node and the two would swap places. Identical live elements are interchangeable, so once every sibling is matched, each set of them is given to its targets in order, when that leaves more nodes in place than the order the passes chose (checked once for all the sets, so many sets stay fast). On a tie the passes' order stays, since moving other nodes instead changes which whitespace is reused. An element holding the user's changes is never identical to anything, but the `isEqualNode` pass gives its identical untouched sibling the target in its place, and the changed element takes a later one. So before that, in both modes, each changed element is ordered the same way with the untouched siblings of its shape, whatever targets they took, among targets with the same outline (the shape without attributes other than `is`) and the same choices, counting duplicates, and for targets holding choices, the same attributes apart from `class` and `style`, as the choice pass prefers. Sets without a changed element are left to the ordering above. The kind pass can give an element a target with another outline, and the choice pass can give a changed control a target holding another value than its identical sibling's, so a changed element never trades into either. The shape, name and kind passes can cross changed elements, like inputs holding typed text, so they take their targets in order with the rest. Matching by name, in the name pass and the outline pass, skips an element that would be replaced rather than morphed, like an input of another type, so a same-name input of the target's type keeps its target and the user's text. The name pass still gives the target such an element once no target can take it to morph, because replacing it asks both vetoes before changing anything, while removing it and adding the target doesn't. This order always wins, even when it leaves fewer nodes in place, since the user's text belongs in its own row, whatever other nodes move (Joel's rule that user intent wins). Before the name and kind passes, when a changed element is still without a target, or an untouched sibling gave one its target, the free elements with the same outline as one take the targets with that outline in order, changed elements first, among the targets those passes would give them, so a typed row isn't given the first target of its tag when that has another outline, and an untouched row goes rather than a typed one when there are fewer targets. A target holding a `morphlex-clobber` element only takes an untouched element in that pass, so an earlier clobbered row doesn't shift the typed rows after it. When ordering, a changed element never trades into a target holding a `morphlex-clobber` element: such a target takes the first untouched sibling, and the other targets take the first free sibling while enough untouched ones are left for those. The equal target a changed element takes is morphed, not skipped. Untouched siblings of the same shape that no target took can stay instead of the ones the passes kept, since removing either leaves the same markup, so the ones that stay are chosen (a dynamic program over the siblings in order) to cross the fewest other matches, where crossing a changed element outweighs crossing all the others, and two changed elements crossing outweighs both, so typed rows of different kinds keep their order. On a tie, or when the program would be too big (a morph removing many of many identical siblings), the passes' choice stays. +The `isEqualNode` pass gives each target the first free equal element, so when an element changes and a sibling is identical to its old version, the unchanged target takes the changed element's live node and the two would swap places. Identical live elements are interchangeable, so once every sibling is matched, each set of them is given to its targets in order, when that leaves more nodes in place than the order the passes chose (checked once for all the sets, so many sets stay fast). On a tie the passes' order stays, since moving other nodes instead changes which whitespace is reused. An element holding the user's changes is never identical to anything, but the `isEqualNode` pass gives its identical untouched sibling the target in its place, and the changed element takes a later one. So before that, in both modes, each changed element is ordered the same way with the untouched siblings of its shape, whatever targets they took, among targets with the same outline (the shape without attributes other than `is`, or a textarea's text, which is only its default value) and the same choices, counting duplicates, and for targets holding choices, the same attributes apart from `class` and `style`, as the choice pass prefers. Sets without a changed element are left to the ordering above. The kind pass can give an element a target with another outline, and the choice pass can give a changed control a target holding another value than its identical sibling's, so a changed element never trades into either. The shape, name and kind passes can cross changed elements, like inputs holding typed text, so they take their targets in order with the rest. Matching by name, in the name pass and the outline pass, skips an element that would be replaced rather than morphed, like an input of another type, so a same-name input of the target's type keeps its target and the user's text. The name pass still gives the target such an element once no target can take it to morph, because replacing it asks both vetoes before changing anything, while removing it and adding the target doesn't. This order always wins, even when it leaves fewer nodes in place, since the user's text belongs in its own row, whatever other nodes move (Joel's rule that user intent wins). Before the name and kind passes, when a changed element is still without a target, or an untouched sibling gave one its target, the free elements with the same outline as one take the targets with that outline in order, changed elements first, among the targets those passes would give them, so a typed row isn't given the first target of its tag when that has another outline, and an untouched row goes rather than a typed one when there are fewer targets. A target holding a `morphlex-clobber` element only takes an untouched element in that pass, so an earlier clobbered row doesn't shift the typed rows after it. When ordering, a changed element never trades into a target holding a `morphlex-clobber` element: such a target takes the first untouched sibling, and the other targets take the first free sibling while enough untouched ones are left for those. The equal target a changed element takes is morphed, not skipped. Untouched siblings of the same shape that no target took can stay instead of the ones the passes kept, since removing either leaves the same markup, so the ones that stay are chosen (a dynamic program over the siblings in order) to cross the fewest other matches, where crossing a changed element outweighs crossing all the others, and two changed elements crossing outweighs both, so typed rows of different kinds keep their order. On a tie, or when the program would be too big (a morph removing many of many identical siblings), the passes' choice stays. ### Whitespace between elements diff --git a/src/morphlex.ts b/src/morphlex.ts index 37b3919..2408e4c 100644 --- a/src/morphlex.ts +++ b/src/morphlex.ts @@ -1700,27 +1700,30 @@ class Morph { this.#options.afterChildrenVisited?.(from) } - // Match elements by isEqualNode. Equal nodes have equal text content, so with many siblings, - // bucket the candidates by it rather than comparing every pair. An element holding the user's changes can't - // equal its target, so it's left for the pass after this one. + // Match elements by isEqualNode. With many siblings, bucket the candidates rather than comparing every pair. An + // element holding the user's changes can't equal its target, so it's left for the pass after this one. #matchEqualElements(siblings: Siblings): void { const { from, to, candidateElements, unmatchedElements, candidateActive, unmatchedActive } = siblings const dirtyElements = this.#dirtyElements - const candidatesByText = - candidateElements.length * unmatchedElements.length > 1024 ? bucketByTextContent(from, candidateElements) : null + const buckets = + candidateElements.length * unmatchedElements.length > 1024 + ? new EqualBuckets(siblings, candidateElements, this.#preserveChanges) + : null for (let i = 0; i < unmatchedElements.length; i++) { const target = unmatchedElements[i]! if (!unmatchedActive[target]) continue const element = to[target] as Element let candidates = candidateElements - if (candidatesByText) { - const bucket = candidatesByText.get(textContentOf(element)!) + let c = 0 + if (buckets) { + const bucket = buckets.get(element) if (bucket === undefined) continue candidates = bucket + c = buckets.firstActive(bucket, candidateActive) } - for (let c = 0; c < candidates.length; c++) { + for (; c < candidates.length; c++) { const candidateIndex = candidates[c]! if (!candidateActive[candidateIndex] || !siblings.sameKind(target, candidateIndex)) continue const candidate = from[candidateIndex] as Element @@ -1730,6 +1733,7 @@ class Morph { siblings.take(target, candidateIndex, Operation.EqualNode) break } + buckets?.fail(candidates) } } } @@ -2273,12 +2277,15 @@ class Morph { if (op[target] !== Operation.EqualNode) changed.push(candidate) } if (changed.length) { - // Equal nodes have equal text content, so with many siblings, compare within its bucket. - const candidatesByText = changed.length * candidates.length > 1024 ? bucketByTextContent(from, candidates) : null - const identicalTo = (candidate: number): Array => - (candidatesByText ? candidatesByText.get(textContentOf(from[candidate]!)!)! : candidates).filter( - (other) => other === candidate || isEqualNode(from[other]!, from[candidate]!), - ) + // With many siblings, compare within the candidate's bucket. + const buckets = + changed.length * candidates.length > 1024 ? new EqualBuckets(siblings, candidates, this.#preserveChanges) : null + const identicalTo = (candidate: number): Array => { + const bucket = buckets ? buckets.get(from[candidate]!)! : candidates + const identical = bucket.filter((other) => other === candidate || isEqualNode(from[other]!, from[candidate]!)) + buckets?.fail(bucket, bucket.length - identical.length) + return identical + } // Ordering a set can cross other matches, so keep the order the passes chose unless ordering leaves more // nodes in place. On a tie, it's other nodes that move, and whitespace is reused around the nodes that stay. const ordered = matches.slice() @@ -3508,6 +3515,61 @@ function orderSets( return reordered } +// Nodes bucketed so equal nodes share a bucket, by text content. Comparisons keep failing in a bucket of nodes with +// the same text that differ in their markup, like empty elements, so once they've cost more than the bucket has +// nodes, it's split by shape and its nodes aren't compared pair by pair. Each bucket of many also skips its taken +// prefix, so many identical siblings aren't scanned again for each target. +class EqualBuckets { + readonly #siblings: Siblings + readonly #ignoresOpen: boolean + readonly #byText: Map> + readonly #state: Map, { taken: number; failures: number; byShape?: Map> }> = new Map() + + constructor(siblings: Siblings, indices: Array, ignoresOpen: boolean) { + this.#siblings = siblings + this.#ignoresOpen = ignoresOpen + this.#byText = bucketByTextContent(siblings.from, indices) + } + + get(node: Node): Array | undefined { + const bucket = this.#byText.get(textContentOf(node)!) + const state = bucket && bucket.length > 1 && this.#state.get(bucket) + if (!state || state.failures <= bucket.length) return bucket + state.byShape ??= bucketByShape(this.#siblings, bucket, this.#ignoresOpen) + return state.byShape.get(this.#siblings.shapeOf(node, this.#ignoresOpen)) + } + + // The first position in the bucket from which candidates may still be active. + firstActive(bucket: Array, active: Uint8Array): number { + if (bucket.length === 1) return 0 + const state = this.#stateOf(bucket) + while (state.taken < bucket.length && !active[bucket[state.taken]!]) state.taken++ + return state.taken + } + + // Notes comparisons in a bucket that found no equal node. + fail(bucket: Array, count = 1): void { + if (bucket.length > 1) this.#stateOf(bucket).failures += count + } + + #stateOf(bucket: Array): { taken: number; failures: number; byShape?: Map> } { + let state = this.#state.get(bucket) + if (!state) this.#state.set(bucket, (state = { taken: 0, failures: 0 })) + return state + } +} + +function bucketByShape(siblings: Siblings, indices: Array, ignoresOpen: boolean): Map> { + const buckets: Map> = new Map() + for (const index of indices) { + const shape = siblings.shapeOf(siblings.from[index]!, ignoresOpen) + const bucket = buckets.get(shape) + if (bucket) bucket.push(index) + else buckets.set(shape, [index]) + } + return buckets +} + function bucketByTextContent(nodes: Array, indices: Array): Map> { const buckets: Map> = new Map() for (let i = 0; i < indices.length; i++) { @@ -3674,7 +3736,8 @@ function shapeOf(node: Node, ignoresOpen: boolean): string { function outlineOf(node: Node): string { if (!isElement(node)) return JSON.stringify([nodeTypeOf(node), node.nodeName, node.nodeValue]) let children = "" - for (const child of childNodesOf(node)) children += outlineOf(child) + // A textarea's text is only its default value, which the user's typing replaces. + if (!isTextAreaElement(node)) for (const child of childNodesOf(node)) children += outlineOf(child) if (isTemplateElement(node)) { children += "<#content" for (const child of node.content.childNodes) children += outlineOf(child) diff --git a/test/new/dirty-textarea.browser.test.ts b/test/new/dirty-textarea.browser.test.ts index 27d313d..c7bb9ba 100644 --- a/test/new/dirty-textarea.browser.test.ts +++ b/test/new/dirty-textarea.browser.test.ts @@ -19,3 +19,20 @@ test("dirty descendant textareas are flagged before morphing", () => { expect((from.firstElementChild as HTMLTextAreaElement).defaultValue).toBe("after") expect(from.firstElementChild?.hasAttribute("morphlex-dirty")).toBe(false) }) + +test("a typed textarea with default text keeps its text when the target has one textarea of its name", () => { + const host = document.createElement("div") + host.innerHTML = `
` + document.body.append(host) + const typed = host.querySelectorAll("textarea")[1]! + typed.value = "typed" + + const template = document.createElement("template") + template.innerHTML = `
` + morph(host.firstElementChild!, template.content.firstElementChild!, { preserveChanges: true }) + + expect(host.querySelectorAll("textarea").length).toBe(1) + expect(host.querySelector("textarea")).toBe(typed) + expect(typed.value).toBe("typed") + host.remove() +}) diff --git a/test/new/equal-buckets.browser.test.ts b/test/new/equal-buckets.browser.test.ts new file mode 100644 index 0000000..a28441f --- /dev/null +++ b/test/new/equal-buckets.browser.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "vitest" +import { morph } from "../../src/morphlex" + +function parse(html: string): Element { + const template = document.createElement("template") + template.innerHTML = html + return template.content.firstElementChild! +} + +function range(length: number): Array { + return Array.from({ length }, (_, i) => i) +} + +test("many empty siblings whose attributes all change keep their nodes in order", () => { + const from = parse( + `
${range(40) + .map((i) => ``) + .join("")}
`, + ) + const icons = Array.from(from.children) + + const to = `
${range(40) + .map((i) => ``) + .join("")}
` + morph(from, parse(to)) + + expect(Array.from(from.children)).toEqual(icons) + expect(from.outerHTML).toBe(to) +}) + +test("many empty siblings that differ in markup still take the equal ones", () => { + const from = parse( + `
${range(40) + .map((i) => ``) + .join("")}
`, + ) + const icons = Array.from(from.children) + + const order = range(40).reverse() + morph(from, parse(`
${order.map((i) => ``).join("")}
`)) + + expect(from.children[0]!.localName).toBe("b") + order.forEach((i, position) => expect(from.children[position + 1]).toBe(icons[i])) +}) + +test("many identical siblings keep their nodes when one changes", () => { + const from = parse(`
    ${"
  • x
  • ".repeat(40)}
`) + const items = Array.from(from.children) + + morph( + from, + parse( + `
    ${range(40) + .map((i) => (i === 20 ? `
  • x
  • ` : "
  • x
  • ")) + .join("")}
`, + ), + ) + + expect(Array.from(from.children)).toEqual(items) + expect(items[20]!.className).toBe("on") +})