Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
93 changes: 78 additions & 15 deletions src/morphlex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -1730,6 +1733,7 @@ class Morph {
siblings.take(target, candidateIndex, Operation.EqualNode)
break
}
buckets?.fail(candidates)
}
}
}
Expand Down Expand Up @@ -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<number> =>
(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<number> => {
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()
Expand Down Expand Up @@ -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<string, Array<number>>
readonly #state: Map<Array<number>, { taken: number; failures: number; byShape?: Map<string, Array<number>> }> = new Map()

constructor(siblings: Siblings, indices: Array<number>, ignoresOpen: boolean) {
this.#siblings = siblings
this.#ignoresOpen = ignoresOpen
this.#byText = bucketByTextContent(siblings.from, indices)
}

get(node: Node): Array<number> | 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<number>, 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<number>, count = 1): void {
if (bucket.length > 1) this.#stateOf(bucket).failures += count
}

#stateOf(bucket: Array<number>): { taken: number; failures: number; byShape?: Map<string, Array<number>> } {
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<number>, ignoresOpen: boolean): Map<string, Array<number>> {
const buckets: Map<string, Array<number>> = 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<ChildNode>, indices: Array<number>): Map<string, Array<number>> {
const buckets: Map<string, Array<number>> = new Map()
for (let i = 0; i < indices.length; i++) {
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions test/new/dirty-textarea.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<div><textarea form="missing" name="t"></textarea><textarea name="t">l1\nl2</textarea></div>`
document.body.append(host)
const typed = host.querySelectorAll("textarea")[1]!
typed.value = "typed"

const template = document.createElement("template")
template.innerHTML = `<div><textarea name="t" form="missing" class="x"></textarea></div>`
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()
})
61 changes: 61 additions & 0 deletions test/new/equal-buckets.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
return Array.from({ length }, (_, i) => i)
}

test("many empty siblings whose attributes all change keep their nodes in order", () => {
const from = parse(
`<div>${range(40)
.map((i) => `<i class="a${i % 3}" data-x="${i}"></i>`)
.join("")}</div>`,
)
const icons = Array.from(from.children)

const to = `<div>${range(40)
.map((i) => `<i class="a${(i + 1) % 3}" data-x="${i}" title="t"></i>`)
.join("")}</div>`
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(
`<div>${range(40)
.map((i) => `<i data-x="${i}"></i>`)
.join("")}</div>`,
)
const icons = Array.from(from.children)

const order = range(40).reverse()
morph(from, parse(`<div><b></b>${order.map((i) => `<i data-x="${i}"></i>`).join("")}</div>`))

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(`<ul>${"<li>x</li>".repeat(40)}</ul>`)
const items = Array.from(from.children)

morph(
from,
parse(
`<ul>${range(40)
.map((i) => (i === 20 ? `<li class="on">x</li>` : "<li>x</li>"))
.join("")}</ul>`,
),
)

expect(Array.from(from.children)).toEqual(items)
expect(items[20]!.className).toBe("on")
})
Loading