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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ A live target that's in the page is inserted with `moveBefore` too (when it's in

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.

### Addresses: `href` and `src`

A non-empty `href` or `src` pairs elements sharing it first, but doesn't stop an element matching by its tag, so a link, image or iframe whose address changes is morphed in place: a focused link keeps focus, and Chromium and WebKit keep a stylesheet applied until its new `href` loads. A `name`, an `id` and form controls still block matching by tag. A script with a `src` (or an SVG script with an `href`) and a media `source` with a `src` are still replaced, because a script doesn't run again when its address changes, and a media element doesn't look at a source again. An address loads as soon as it changes, with the attributes the element has then, so `#visitAttributes` updates `srcdoc`, `src` and `href` after every other attribute is added, changed or removed, so an iframe's new page gets its `sandbox` and a link's new sheet its `integrity`. `srcdoc` goes first, since it takes precedence over `src`. A link whose `rel` changes along with its `href` can briefly load the old `href` for its new `rel`; that's rare enough to leave.

### Whitespace between elements

Whitespace text nodes are only ever matched with whitespace. The live whitespace stays in place while the children are placed, and target whitespace reuses the live whitespace at the insertion point, so unchanged whitespace isn't removed and added again. Whitespace nothing reused is removed afterwards.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ When morphing the children of an element, Morphlex pairs each new child with an
4. An element that contains one of the same `id`s somewhere inside it.
5. With `preserveChanges`, a checkbox, radio or option the user changed is paired with one making the same choice, such as the same name and value in the same form, and an element such as a `<label>` holding one is paired with an element holding the same choice. This keeps the user’s pick in place when items are added or reordered around it.
6. An element with the same non-empty `name`, `href` or `src` attribute.
7. Any element with the same tag name, as long as neither element has an `id`, a non-empty `name`, `href` or `src`, and neither is a form control. Ids inside the elements don’t count, so a wrapper without its own identity still pairs by position.
7. Any element with the same tag name, as long as neither element has an `id` or a non-empty `name`, and neither is a form control. Ids inside the elements don’t count, so a wrapper without its own identity still pairs by position, and an `href` or `src` doesn’t either, so a link, image or iframe whose address changes is updated in place. A `<script>` or media `<source>` with a `src` still isn’t paired by tag, since it wouldn’t be loaded again.

When a new child’s unique `id` belongs to a live element under another parent that can be morphed into it, the new child isn’t paired here. That element moves to the new child’s place instead (see [Options](#options)).

Expand Down
86 changes: 58 additions & 28 deletions src/morphlex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ const CHOICE_PASSES = [

const STYLING_ATTRIBUTES = ["class", "style"]
const OPEN_ATTRIBUTE = ["open"]
// Attributes that load what they point to as soon as they change.
const ADDRESS_ATTRIBUTES = new Set(["src", "href", "srcdoc"])
// Elements that don't load their new address when it changes, so they're replaced rather than matched by tag.
const REPLACED_ON_ADDRESS_CHANGE = new Set(["script", "source"])

const Operation = {
EqualNode: 0,
Expand Down Expand Up @@ -1508,36 +1512,19 @@ class Morph {
// The user toggles `open` on these elements, so with `preserveChanges` it's neither added nor removed.
const keepsOpen = this.#preserveChanges && hasOpenState(from)

// First pass: update/add attributes from reference (iterate forwards)
// First pass: update/add attributes from reference (iterate forwards). An address loads as soon as it
// changes, with the attributes the element has then, like an iframe's `sandbox` or a link's `integrity`,
// so addresses are updated last, after the other attributes are added, changed and removed.
const toAttributes = attributesOf(to)
let addresses: Array<Attr> | null = null
for (let i = 0; i < toAttributes.length; i++) {
const attribute = toAttributes[i]!
const { name, localName, value, namespaceURI } = attribute
// Adding `open` would open it, but changing the value of an existing one is fine.
if (keepsOpen && name === "open" && namespaceURI === null && !hasAttributeNS(from, null, "open")) continue
const oldValue = getAttributeNS(from, namespaceURI, localName)

if (oldValue === value) continue
if (this.#options.beforeAttributeUpdated?.(from, name, value) ?? true) {
// Go through `Attr` nodes, because `setAttribute` rejects names the parser accepts, like `@click`.
// Look the attribute up after the callback, which may have removed or replaced it.
const radios = namespaceURI ? null : this.#uncheckRadiosForAttribute(from, name, value)
// Checking a radio unchecks the rest of its group, which gets its check back if the radio then leaves.
const group =
name === "checked" && namespaceURI === null && isRadio(from) && !from.checked ? checkedRadiosInGroup(from) : null
const existing = getAttributeNodeNS(from, namespaceURI, localName)
if (existing) {
existing.value = value
} else if (details && name === "open" && namespaceURI === null) {
this.#openDetailsItem(from, value)
} else {
setAttributeNodeNS(from, attribute.cloneNode() as Attr)
}
this.#checkRadios(radios)
if (group) this.#noteDisplacedRadios(group, from as HTMLInputElement)
this.#options.afterAttributeUpdated?.(from, name, oldValue)
if (attribute.namespaceURI === null && ADDRESS_ATTRIBUTES.has(attribute.name)) {
// An iframe's `srcdoc` takes precedence over its `src`, so it goes first.
if (attribute.name === "srcdoc") (addresses ??= []).unshift(attribute)
else (addresses ??= []).push(attribute)
} else {
this.#noteVetoedAttribute(from, name, namespaceURI)
this.#updateAttribute(from, attribute, keepsOpen, details)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Neutralize the old link href before changing rel

When a connected link changes both relationship and address, such as <link rel="canonical" href="/old"> becoming <link rel="stylesheet" href="/new.css">, this branch applies rel while the old href is still present because href is deferred. Creating the stylesheet relationship on an already-connected link can therefore fetch /old as CSS before the final address is installed, whereas inserting the target would only fetch /new.css; the same problem occurs when removing disabled enables a stylesheet with its old address. Temporarily neutralize a changed link href before applying resource-enabling attributes, then install the target address last, consistent with the HTML link processing model.

AGENTS.md reference: AGENTS.md:L65-L67

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. It needs a single <link> to change both its rel and its href in one update, for example canonical to stylesheet, which is rare in real pages. The fix would mean temporarily clearing href and then restoring it, with veto callbacks for each step, which is more code than the case justifies. The worst outcome is one extra request for the old URL, after which the new href is installed. I've noted this as a known limitation in AGENTS.md.


Generated by Claude Code

}
}

Expand Down Expand Up @@ -1566,13 +1553,48 @@ class Morph {
}
}

if (addresses) {
for (const attribute of addresses) this.#updateAttribute(from, attribute, keepsOpen, details)
Comment thread
joeldrapper marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Set the new iframe src before removing srcdoc

When a matched iframe changes from <iframe src="old" srcdoc="…"> to <iframe src="new">, the removal pass deletes srcdoc before this deferred loop installs the new src. Removing srcdoc immediately processes the iframe attributes, so the browser can start a navigation or request to the old fallback URL before navigating to the intended new URL; replacing the iframe did not expose that intermediate request. Set the new src while srcdoc still masks it, then remove srcdoc, as implied by the iframe processing model.

AGENTS.md reference: AGENTS.md:L65-L67

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. It only happens when an iframe switches from srcdoc to a new src in a single morph. Even then, the worst case is that the old src starts loading and the new src replaces it straight away. Handling it would need yet another ordering special case in #visitAttributes for one rare transition. I've put the call to the maintainer.


Generated by Claude Code

}

if (details) this.#noteIntendedOpen(from, to, open)

if (!this.#preserveChanges) {
this.#resetFormProperties(from, to)
}
}

// Add the attribute, or update its value, asking the callbacks first.
#updateAttribute(from: Element, attribute: Attr, keepsOpen: boolean, details: boolean): void {
const { name, localName, value, namespaceURI } = attribute
// Adding `open` would open it, but changing the value of an existing one is fine.
if (keepsOpen && name === "open" && namespaceURI === null && !hasAttributeNS(from, null, "open")) return
const oldValue = getAttributeNS(from, namespaceURI, localName)

if (oldValue === value) return
if (this.#options.beforeAttributeUpdated?.(from, name, value) ?? true) {
// Go through `Attr` nodes, because `setAttribute` rejects names the parser accepts, like `@click`.
// Look the attribute up after the callback, which may have removed or replaced it.
const radios = namespaceURI ? null : this.#uncheckRadiosForAttribute(from, name, value)
// Checking a radio unchecks the rest of its group, which gets its check back if the radio then leaves.
const group =
name === "checked" && namespaceURI === null && isRadio(from) && !from.checked ? checkedRadiosInGroup(from) : null
const existing = getAttributeNodeNS(from, namespaceURI, localName)
if (existing) {
existing.value = value
} else if (details && name === "open" && namespaceURI === null) {
this.#openDetailsItem(from, value)
} else {
setAttributeNodeNS(from, attribute.cloneNode() as Attr)
}
this.#checkRadios(radios)
if (group) this.#noteDisplacedRadios(group, from as HTMLInputElement)
this.#options.afterAttributeUpdated?.(from, name, oldValue)
} else {
this.#noteVetoedAttribute(from, name, namespaceURI)
}
}

// Note the `open` value the morph means an accordion item to have, whatever the browser does to it:
// the target's, unless the update was vetoed, or `preserveChanges` keeps the item open or closed.
#noteIntendedOpen(details: Element, to: Element, open: string | null): void {
Expand Down Expand Up @@ -3901,9 +3923,17 @@ function canMorphElementInPlace(from: Element, to: Element): boolean {
return true
}

// Only an element's own identity counts, so a wrapper holding an id'd element still matches by its tag.
// Only an element's own identity counts, so a wrapper holding an id'd element still matches by its tag. An `href` or
// `src` pairs elements sharing it first, but doesn't stop one matching by its tag, so a link or an iframe whose
// address changes is updated in place. A script doesn't run again when its `src` (or an SVG script's `href`)
// changes, and a media element doesn't look at a source again, so they're still replaced.
function canSoftMatchByTagName(element: Element): boolean {
return idOf(element) === "" && !isFormControl(element) && !hasMatchKeyAttribute(element)
return (
idOf(element) === "" &&
!isFormControl(element) &&
!getAttribute(element, "name") &&
!(REPLACED_ON_ADDRESS_CHANGE.has(localNameOf(element)) && (getAttribute(element, "src") || getAttribute(element, "href")))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset media elements when src is removed

When a live <audio src="old"> or <video src="old"> is matched to a target without src, this new soft-match eligibility reuses the media element and the attribute pass merely removes src. Removing a media element's src does not invoke its load algorithm, so the old resource can remain selected or playing, and any newly inserted <source> children are not selected; previously the non-empty src prevented this tag match and replacement reset the element. Exclude this transition from in-place matching or explicitly reload the media element after the morph, consistent with the media resource processing model.

AGENTS.md reference: AGENTS.md:L65-L67

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. It needs a media element to switch from a src attribute to <source> children in a single update, which is rare. Changing src from one value to another still reloads the element, because setting src runs the load algorithm. Replacing every media element, or calling load() after the morph, would cost more than this case justifies. I've put the call to the maintainer.


Generated by Claude Code

)
}

// Whether the elements have the same non-empty name, href or src.
Expand Down
146 changes: 146 additions & 0 deletions test/new/href-src-in-place.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { expect, test } from "vitest"
import { morph } from "../../src/morphlex"
import { observeMutations } from "./utils"

function morphChildren(fromHTML: string, toHTML: string) {
const from = document.createElement("div")
from.innerHTML = fromHTML
const before = Array.from(from.childNodes)
const to = document.createElement("div")
to.innerHTML = toHTML
const expected = to.innerHTML

const mutations = observeMutations(from, () => morph(from, to))

expect(from.innerHTML).toBe(expected)
return {
original: Array.from(from.childNodes, (node) => before.indexOf(node)),
added: mutations.nodesAdded,
removed: mutations.nodesRemoved,
}
}

test.each([
[`<a href="/old">old</a>`, `<a href="/new">new</a>`],
[`<img src="/old.png">`, `<img src="/new.png">`],
[`<iframe src="about:blank#old"></iframe>`, `<iframe src="about:blank#new"></iframe>`],
[`<a href="/x">old</a>`, `<a href="">new</a>`],
[`<a href="/x">old</a>`, `<a>new</a>`],
[`<a>old</a>`, `<a href="/x">new</a>`],
[`<link rel="stylesheet" href="data:text/css,p{}">`, `<link rel="stylesheet" href="data:text/css,p{ }">`],
])("an element whose href or src changes is morphed in place: %s to %s", (fromHTML, toHTML) => {
expect(morphChildren(fromHTML, toHTML)).toEqual({ original: [0], added: 0, removed: 0 })
})

test("an element keeps the one sharing its href before others take it by position", () => {
expect(morphChildren(`<a href="/a">a</a><a href="/b">b</a>`, `<a href="/b">b</a><a href="/c">c</a>`)).toEqual({
original: [1, 0],
added: 1,
removed: 1,
})
})

test("a focused link keeps focus when its href changes", () => {
const from = document.createElement("div")
from.innerHTML = `<a href="/old">Link</a>`
document.body.append(from)
const link = from.firstElementChild as HTMLAnchorElement
link.focus()

const to = document.createElement("div")
to.innerHTML = `<a href="/new">Link</a>`
morph(from, to)

expect(from.firstElementChild).toBe(link)
expect(link.getAttribute("href")).toBe("/new")
expect(document.activeElement).toBe(link)
from.remove()
})

test("an element with a name still isn't matched by tag", () => {
expect(morphChildren(`<a name="old">old</a>`, `<a name="new">new</a>`)).toEqual({ original: [-1], added: 1, removed: 1 })
})

test("an element with a name and an href still isn't matched by tag", () => {
expect(morphChildren(`<a name="n" href="/old">old</a>`, `<a name="n2" href="/new">new</a>`)).toEqual({
original: [-1],
added: 1,
removed: 1,
})
})

test("a form control with a changed src is still replaced", () => {
expect(morphChildren(`<input type="image" src="/old.png">`, `<input type="image" src="/new.png">`)).toEqual({
original: [-1],
added: 1,
removed: 1,
})
})

test("a script whose src changes is still replaced, so the new script can be run", () => {
expect(
morphChildren(`<script src="data:text/javascript,0"></script>`, `<script src="data:text/javascript,1"></script>`),
).toEqual({
original: [-1],
added: 1,
removed: 1,
})
})

test("an SVG script whose href changes is still replaced", () => {
const from = document.createElement("div")
from.innerHTML = `<svg><script href="data:text/javascript,0"></script></svg>`
const script = from.querySelector("script")
const to = document.createElement("div")
to.innerHTML = `<svg><script href="data:text/javascript,1"></script></svg>`
const expected = to.innerHTML

morph(from, to)

expect(from.querySelector("script")).not.toBe(script)
expect(from.innerHTML).toBe(expected)
})

// The order the attributes of the element were updated in.
function attributeUpdates(fromHTML: string, toHTML: string): Array<string> {
const from = document.createElement("div")
from.innerHTML = fromHTML
const to = document.createElement("div")
to.innerHTML = toHTML
const updates: Array<string> = []

morph(from, to, { afterAttributeUpdated: (_element, name) => updates.push(name) })

expect(from.innerHTML).toBe(to.innerHTML)
return updates
}

test("an iframe's src changes after its sandbox, so the new page loads sandboxed", () => {
expect(attributeUpdates(`<iframe src="about:blank#a"></iframe>`, `<iframe src="about:blank#b" sandbox=""></iframe>`)).toEqual([
"sandbox",
"src",
])
})

test("a link's href changes after its integrity is updated or removed", () => {
expect(
attributeUpdates(
`<link rel="stylesheet" href="data:text/css,a" integrity="sha256-a" crossorigin="">`,
`<link rel="stylesheet" href="data:text/css,b" integrity="sha256-b">`,
),
).toEqual(["integrity", "crossorigin", "href"])
})

test("an iframe's srcdoc changes before its src, so the src never starts loading", () => {
expect(
attributeUpdates(`<iframe src="about:blank#a"></iframe>`, `<iframe src="about:blank#b" srcdoc="<p>b</p>"></iframe>`),
).toEqual(["srcdoc", "src"])
})

test("a media source whose src changes is still replaced, so the media element picks it up", () => {
expect(morphChildren(`<source src="data:video/mp4,a">`, `<source src="data:video/mp4,b">`)).toEqual({
original: [-1],
added: 1,
removed: 1,
})
})
4 changes: 0 additions & 4 deletions test/new/sibling-matching.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,3 @@ test.each([
])("an element with an empty name, href or src is morphed in place: %s to %s", (fromHTML, toHTML) => {
expect(morphChildren(fromHTML, toHTML)).toMatchObject({ original: [0], added: 0, removed: 0 })
})

test("an element with a non-empty href isn't paired with one whose href is empty", () => {
expect(morphChildren(`<a href="/x">old</a>`, `<a href="">new</a>`)).toMatchObject({ original: [-1], added: 1, removed: 1 })
})
24 changes: 0 additions & 24 deletions test/new/tagname-exclusions.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,30 +29,6 @@ test("elements with unmatched name attribute are not matched by tag name", () =>
expect(a.children[0]!.getAttribute("name")).toBe("new-anchor")
})

test("elements with unmatched href attribute are not matched by tag name", () => {
const a = dom(`<div><a href="/old">old</a></div>`)
const b = dom(`<div><a href="/new">new</a></div>`)

const original = a.children[0]!

morph(a, b)

expect(a.children[0]).not.toBe(original)
expect(a.children[0]!.getAttribute("href")).toBe("/new")
})

test("elements with unmatched src attribute are not matched by tag name", () => {
const a = dom(`<div><img src="/old.png"></div>`)
const b = dom(`<div><img src="/new.png"></div>`)

const original = a.children[0]!

morph(a, b)

expect(a.children[0]).not.toBe(original)
expect(a.children[0]!.getAttribute("src")).toBe("/new.png")
})

test("input elements are not matched by tag name", () => {
const a = dom(`<div><input type="text" class="old"></div>`)
const b = dom(`<div><input type="text" class="new"></div>`)
Expand Down
Loading