Skip to content

Commit 10224e0

Browse files
Release v0.7.4.
1 parent 7c4d99e commit 10224e0

13 files changed

+349
-115
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
* Removed:
66
* Remove AMD publish target since its EOL: https://github.com/requirejs/requirejs/issues/1816#issuecomment-707503323
77

8+
## [0.7.4] - 2025-09-29
9+
10+
* Fixed:
11+
* Optimize focus preservation checking for big perf win (@botandrose) #137
12+
* Fix incorrect morph when elements contain attributes like name="id" (@botandrose, @kobutri) #136
13+
814
## [0.7.3] - 2025-03-05
915

1016
* Fixed:

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ A testimonial:
3838
3939
## Installing
4040

41-
Idiomorph is a small (3.2k min/gz'd), dependency free JavaScript library. The `/dist/idiomorph.js` file can be included
41+
Idiomorph is a small (3.3k min/gz'd), dependency free JavaScript library. The `/dist/idiomorph.js` file can be included
4242
directly in a browser:
4343

4444
```html
45-
<script src="https://unpkg.com/[email protected].3"></script>
45+
<script src="https://unpkg.com/[email protected].4"></script>
4646
```
4747

4848
For production systems we recommend downloading and vendoring the library.

dist/idiomorph-ext.esm.js

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ var Idiomorph = (function () {
114114
* @property {ConfigInternal['callbacks']} callbacks
115115
* @property {ConfigInternal['head']} head
116116
* @property {HTMLDivElement} pantry
117+
* @property {Element[]} activeElementAndParents
117118
*/
118119

119120
//=============================================================================
@@ -227,7 +228,10 @@ var Idiomorph = (function () {
227228

228229
const results = fn();
229230

230-
if (activeElementId && activeElementId !== document.activeElement?.id) {
231+
if (
232+
activeElementId &&
233+
activeElementId !== document.activeElement?.getAttribute("id")
234+
) {
231235
activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`);
232236
activeElement?.focus();
233237
}
@@ -306,17 +310,23 @@ var Idiomorph = (function () {
306310
}
307311

308312
// if the matching node is elsewhere in the original content
309-
if (newChild instanceof Element && ctx.persistentIds.has(newChild.id)) {
310-
// move it and all its children here and morph
311-
const movedChild = moveBeforeById(
312-
oldParent,
313-
newChild.id,
314-
insertionPoint,
315-
ctx,
313+
if (newChild instanceof Element) {
314+
// we can pretend the id is non-null because the next `.has` line will reject it if not
315+
const newChildId = /** @type {String} */ (
316+
newChild.getAttribute("id")
316317
);
317-
morphNode(movedChild, newChild, ctx);
318-
insertionPoint = movedChild.nextSibling;
319-
continue;
318+
if (ctx.persistentIds.has(newChildId)) {
319+
// move it and all its children here and morph
320+
const movedChild = moveBeforeById(
321+
oldParent,
322+
newChildId,
323+
insertionPoint,
324+
ctx,
325+
);
326+
morphNode(movedChild, newChild, ctx);
327+
insertionPoint = movedChild.nextSibling;
328+
continue;
329+
}
320330
}
321331

322332
// last resort: insert the new node from scratch
@@ -426,7 +436,8 @@ var Idiomorph = (function () {
426436

427437
// if the current node contains active element, stop looking for better future matches,
428438
// because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus
429-
if (cursor.contains(document.activeElement)) break;
439+
// @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion
440+
if (ctx.activeElementAndParents.includes(cursor)) break;
430441

431442
cursor = cursor.nextSibling;
432443
}
@@ -476,7 +487,9 @@ var Idiomorph = (function () {
476487
// If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing.
477488
// We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
478489
// its not persistent, and new nodes can't have any hidden state.
479-
(!oldElt.id || oldElt.id === newElt.id)
490+
// We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment
491+
(!oldElt.getAttribute?.("id") ||
492+
oldElt.getAttribute?.("id") === newElt.getAttribute?.("id"))
480493
);
481494
}
482495

@@ -540,7 +553,9 @@ var Idiomorph = (function () {
540553
const target =
541554
/** @type {Element} - will always be found */
542555
(
543-
(ctx.target.id === id && ctx.target) ||
556+
// ctx.target.id unsafe because of form input shadowing
557+
// ctx.target could be a document fragment which doesn't have `getAttribute`
558+
(ctx.target.getAttribute?.("id") === id && ctx.target) ||
544559
ctx.target.querySelector(`[id="${id}"]`) ||
545560
ctx.pantry.querySelector(`[id="${id}"]`)
546561
);
@@ -558,7 +573,8 @@ var Idiomorph = (function () {
558573
* @param {MorphContext} ctx
559574
*/
560575
function removeElementFromAncestorsIdMaps(element, ctx) {
561-
const id = element.id;
576+
// we know id is non-null String, because this function is only called on elements with ids
577+
const id = /** @type {String} */ (element.getAttribute("id"));
562578
/** @ts-ignore - safe to loop in this way **/
563579
while ((element = element.parentNode)) {
564580
let idSet = ctx.idMap.get(element);
@@ -998,6 +1014,7 @@ var Idiomorph = (function () {
9981014
idMap: idMap,
9991015
persistentIds: persistentIds,
10001016
pantry: createPantry(),
1017+
activeElementAndParents: createActiveElementAndParents(oldNode),
10011018
callbacks: mergedConfig.callbacks,
10021019
head: mergedConfig.head,
10031020
};
@@ -1038,6 +1055,24 @@ var Idiomorph = (function () {
10381055
return pantry;
10391056
}
10401057

1058+
/**
1059+
* @param {Element} oldNode
1060+
* @returns {Element[]}
1061+
*/
1062+
function createActiveElementAndParents(oldNode) {
1063+
/** @type {Element[]} */
1064+
let activeElementAndParents = [];
1065+
let elt = document.activeElement;
1066+
if (elt?.tagName !== "BODY" && oldNode.contains(elt)) {
1067+
while (elt) {
1068+
activeElementAndParents.push(elt);
1069+
if (elt === oldNode) break;
1070+
elt = elt.parentElement;
1071+
}
1072+
}
1073+
return activeElementAndParents;
1074+
}
1075+
10411076
/**
10421077
* Returns all elements with an ID contained within the root element and its descendants
10431078
*
@@ -1046,7 +1081,8 @@ var Idiomorph = (function () {
10461081
*/
10471082
function findIdElements(root) {
10481083
let elements = Array.from(root.querySelectorAll("[id]"));
1049-
if (root.id) {
1084+
// root could be a document fragment which doesn't have `getAttribute`
1085+
if (root.getAttribute?.("id")) {
10501086
elements.push(root);
10511087
}
10521088
return elements;
@@ -1065,7 +1101,9 @@ var Idiomorph = (function () {
10651101
*/
10661102
function populateIdMapWithTree(idMap, persistentIds, root, elements) {
10671103
for (const elt of elements) {
1068-
if (persistentIds.has(elt.id)) {
1104+
// we can pretend id is non-null String, because the .has line will reject it immediately if not
1105+
const id = /** @type {String} */ (elt.getAttribute("id"));
1106+
if (persistentIds.has(id)) {
10691107
/** @type {Element|null} */
10701108
let current = elt;
10711109
// walk up the parent hierarchy of that element, adding the id
@@ -1077,7 +1115,7 @@ var Idiomorph = (function () {
10771115
idSet = new Set();
10781116
idMap.set(current, idSet);
10791117
}
1080-
idSet.add(elt.id);
1118+
idSet.add(id);
10811119

10821120
if (current === root) break;
10831121
current = current.parentElement;

dist/idiomorph-ext.js

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ var Idiomorph = (function () {
112112
* @property {ConfigInternal['callbacks']} callbacks
113113
* @property {ConfigInternal['head']} head
114114
* @property {HTMLDivElement} pantry
115+
* @property {Element[]} activeElementAndParents
115116
*/
116117

117118
//=============================================================================
@@ -225,7 +226,10 @@ var Idiomorph = (function () {
225226

226227
const results = fn();
227228

228-
if (activeElementId && activeElementId !== document.activeElement?.id) {
229+
if (
230+
activeElementId &&
231+
activeElementId !== document.activeElement?.getAttribute("id")
232+
) {
229233
activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`);
230234
activeElement?.focus();
231235
}
@@ -304,17 +308,23 @@ var Idiomorph = (function () {
304308
}
305309

306310
// if the matching node is elsewhere in the original content
307-
if (newChild instanceof Element && ctx.persistentIds.has(newChild.id)) {
308-
// move it and all its children here and morph
309-
const movedChild = moveBeforeById(
310-
oldParent,
311-
newChild.id,
312-
insertionPoint,
313-
ctx,
311+
if (newChild instanceof Element) {
312+
// we can pretend the id is non-null because the next `.has` line will reject it if not
313+
const newChildId = /** @type {String} */ (
314+
newChild.getAttribute("id")
314315
);
315-
morphNode(movedChild, newChild, ctx);
316-
insertionPoint = movedChild.nextSibling;
317-
continue;
316+
if (ctx.persistentIds.has(newChildId)) {
317+
// move it and all its children here and morph
318+
const movedChild = moveBeforeById(
319+
oldParent,
320+
newChildId,
321+
insertionPoint,
322+
ctx,
323+
);
324+
morphNode(movedChild, newChild, ctx);
325+
insertionPoint = movedChild.nextSibling;
326+
continue;
327+
}
318328
}
319329

320330
// last resort: insert the new node from scratch
@@ -424,7 +434,8 @@ var Idiomorph = (function () {
424434

425435
// if the current node contains active element, stop looking for better future matches,
426436
// because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus
427-
if (cursor.contains(document.activeElement)) break;
437+
// @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion
438+
if (ctx.activeElementAndParents.includes(cursor)) break;
428439

429440
cursor = cursor.nextSibling;
430441
}
@@ -474,7 +485,9 @@ var Idiomorph = (function () {
474485
// If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing.
475486
// We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
476487
// its not persistent, and new nodes can't have any hidden state.
477-
(!oldElt.id || oldElt.id === newElt.id)
488+
// We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment
489+
(!oldElt.getAttribute?.("id") ||
490+
oldElt.getAttribute?.("id") === newElt.getAttribute?.("id"))
478491
);
479492
}
480493

@@ -538,7 +551,9 @@ var Idiomorph = (function () {
538551
const target =
539552
/** @type {Element} - will always be found */
540553
(
541-
(ctx.target.id === id && ctx.target) ||
554+
// ctx.target.id unsafe because of form input shadowing
555+
// ctx.target could be a document fragment which doesn't have `getAttribute`
556+
(ctx.target.getAttribute?.("id") === id && ctx.target) ||
542557
ctx.target.querySelector(`[id="${id}"]`) ||
543558
ctx.pantry.querySelector(`[id="${id}"]`)
544559
);
@@ -556,7 +571,8 @@ var Idiomorph = (function () {
556571
* @param {MorphContext} ctx
557572
*/
558573
function removeElementFromAncestorsIdMaps(element, ctx) {
559-
const id = element.id;
574+
// we know id is non-null String, because this function is only called on elements with ids
575+
const id = /** @type {String} */ (element.getAttribute("id"));
560576
/** @ts-ignore - safe to loop in this way **/
561577
while ((element = element.parentNode)) {
562578
let idSet = ctx.idMap.get(element);
@@ -996,6 +1012,7 @@ var Idiomorph = (function () {
9961012
idMap: idMap,
9971013
persistentIds: persistentIds,
9981014
pantry: createPantry(),
1015+
activeElementAndParents: createActiveElementAndParents(oldNode),
9991016
callbacks: mergedConfig.callbacks,
10001017
head: mergedConfig.head,
10011018
};
@@ -1036,6 +1053,24 @@ var Idiomorph = (function () {
10361053
return pantry;
10371054
}
10381055

1056+
/**
1057+
* @param {Element} oldNode
1058+
* @returns {Element[]}
1059+
*/
1060+
function createActiveElementAndParents(oldNode) {
1061+
/** @type {Element[]} */
1062+
let activeElementAndParents = [];
1063+
let elt = document.activeElement;
1064+
if (elt?.tagName !== "BODY" && oldNode.contains(elt)) {
1065+
while (elt) {
1066+
activeElementAndParents.push(elt);
1067+
if (elt === oldNode) break;
1068+
elt = elt.parentElement;
1069+
}
1070+
}
1071+
return activeElementAndParents;
1072+
}
1073+
10391074
/**
10401075
* Returns all elements with an ID contained within the root element and its descendants
10411076
*
@@ -1044,7 +1079,8 @@ var Idiomorph = (function () {
10441079
*/
10451080
function findIdElements(root) {
10461081
let elements = Array.from(root.querySelectorAll("[id]"));
1047-
if (root.id) {
1082+
// root could be a document fragment which doesn't have `getAttribute`
1083+
if (root.getAttribute?.("id")) {
10481084
elements.push(root);
10491085
}
10501086
return elements;
@@ -1063,7 +1099,9 @@ var Idiomorph = (function () {
10631099
*/
10641100
function populateIdMapWithTree(idMap, persistentIds, root, elements) {
10651101
for (const elt of elements) {
1066-
if (persistentIds.has(elt.id)) {
1102+
// we can pretend id is non-null String, because the .has line will reject it immediately if not
1103+
const id = /** @type {String} */ (elt.getAttribute("id"));
1104+
if (persistentIds.has(id)) {
10671105
/** @type {Element|null} */
10681106
let current = elt;
10691107
// walk up the parent hierarchy of that element, adding the id
@@ -1075,7 +1113,7 @@ var Idiomorph = (function () {
10751113
idSet = new Set();
10761114
idMap.set(current, idSet);
10771115
}
1078-
idSet.add(elt.id);
1116+
idSet.add(id);
10791117

10801118
if (current === root) break;
10811119
current = current.parentElement;

0 commit comments

Comments
 (0)