Repository navigation
Update links, images and iframes in place when href or src changes #201
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
07bdf28
415d142
b6a20ed
0e61474
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1566,13 +1553,48 @@ class Morph { | |
| } | ||
| } | ||
|
|
||
| if (addresses) { | ||
| for (const attribute of addresses) this.#updateAttribute(from, attribute, keepsOpen, details) | ||
|
joeldrapper marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a matched iframe changes from AGENTS.md reference: AGENTS.md:L65-L67 Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not changing this. It only happens when an iframe switches from 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 { | ||
|
|
@@ -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"))) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a live AGENTS.md reference: AGENTS.md:L65-L67 Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not changing this. It needs a media element to switch from a Generated by Claude Code |
||
| ) | ||
| } | ||
|
|
||
| // Whether the elements have the same non-empty name, href or src. | ||
|
|
||
| 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, | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 appliesrelwhile the oldhrefis still present becausehrefis deferred. Creating the stylesheet relationship on an already-connected link can therefore fetch/oldas CSS before the final address is installed, whereas inserting the target would only fetch/new.css; the same problem occurs when removingdisabledenables a stylesheet with its old address. Temporarily neutralize a changed linkhrefbefore 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 👍 / 👎.
There was a problem hiding this comment.
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 itsreland itshrefin one update, for example canonical to stylesheet, which is rare in real pages. The fix would mean temporarily clearinghrefand 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 newhrefis installed. I've noted this as a known limitation in AGENTS.md.Generated by Claude Code