• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

divio / django-cms / #30500

03 Apr 2026 02:37PM UTC coverage: 75.889% (-14.3%) from 90.223%
#30500

push

travis-ci

web-flow
Merge 112566057 into 37f315595

1149 of 1712 branches covered (67.11%)

120 of 124 new or added lines in 1 file covered. (96.77%)

599 existing lines in 10 files now uncovered.

2688 of 3542 relevant lines covered (75.89%)

26.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.77
/cms/static/cms/js/modules/dom-diff.js
1
/* eslint-env browser */
2
/* global DOMParser */
3
/*
4
 * Copyright https://github.com/django-cms/django-cms
5
 * Simple DOM diffing replacement using native browser APIs
6
 * Replaces diff-dom
7
 */
8

9
/**
10
 * Converts a DOM node to a plain object representation
11
 * @param {Node} node - DOM node to convert
12
 * @returns {Object} Object representation of the node
13
 */
14
export function nodeToObj(node) {
15
    if (!node || !node.nodeType) {
62✔
16
        return null;
4✔
17
    }
18

19
    if (node.nodeType === Node.TEXT_NODE) {
58✔
20
        return {
16✔
21
            nodeName: '#text',
22
            data: node.data
23
        };
24
    }
25

26
    if (node.nodeType === Node.COMMENT_NODE) {
42✔
27
        return {
5✔
28
            nodeName: '#comment',
29
            data: node.data
30
        };
31
    }
32

33
    if (node.nodeType === Node.ELEMENT_NODE) {
37!
34
        const obj = {
37✔
35
            nodeName: node.nodeName,
36
            attributes: {},
37
            childNodes: []
38
        };
39

40
        // Copy attributes
41
        for (const attr of node.attributes) {
37✔
42
            obj.attributes[attr.name] = attr.value;
14✔
43
        }
44

45
        // Copy child nodes (skip unsupported node types)
46
        for (const child of node.childNodes) {
37✔
47
            const childObj = nodeToObj(child);
43✔
48

49
            if (childObj) {
43!
50
                obj.childNodes.push(childObj);
43✔
51
            }
52
        }
53

54
        return obj;
37✔
55
    }
56

NEW
57
    return null;
×
58
}
59

60
/**
61
 * Simple DOM differ that uses native browser APIs
62
 * This is a lightweight replacement for DiffDOM
63
 */
64
export class DiffDOM {
65
    constructor() {
66
        // DOMParser is supported by all browsers in our Browserslist; we can rely on it.
67
        this.parser = new DOMParser();
159✔
68
    }
69

70
    /**
71
     * Calculate diff between old node and new HTML/object
72
     * Note: This is a simplified version that doesn't return a diff object
73
     * Instead, we'll apply changes directly in the apply() method
74
     *
75
     * @param {Node} oldNode - Current DOM node
76
     * @param {string|Object} newContent - New HTML string or node object
77
     * @returns {Object} Diff information
78
     */
79
    diff(oldNode, newContent) {
80
        let newNode;
81

82
        if (typeof newContent === 'string') {
26✔
83
            // Parse HTML string via DOMParser; unwrap the outer container (div) and use its children
84
            const doc = this.parser.parseFromString(newContent, 'text/html');
14✔
85

86
            newNode = doc.body.firstChild || doc.head.firstChild;
14!
87
        } else if (typeof newContent === 'object' && newContent.nodeName) {
12!
88
            // Convert object to DOM node
89
            newNode = this._objToNode(newContent);
12✔
90
        } else {
NEW
91
            newNode = newContent;
×
92
        }
93

94
        return {
26✔
95
            oldNode,
96
            newNode
97
        };
98
    }
99

100
    /**
101
     * Apply diff to update the DOM
102
     * @param {Node} target - Target node to update
103
     * @param {Object} diff - Diff object from diff()
104
     */
105
    apply(target, diff) {
106
        const { newNode } = diff;
26✔
107

108
        if (!newNode) {
26!
NEW
109
            return;
×
110
        }
111

112
        // Text nodes have no innerHTML or childNodes — handle them directly
113
        if (newNode.nodeType === Node.TEXT_NODE || newNode.nodeType === Node.COMMENT_NODE) {
26✔
114
            target.textContent = newNode.textContent;
2✔
115
            return;
2✔
116
        }
117

118
        // Fast path: skip entirely when nothing changed
119
        if (target.innerHTML === newNode.innerHTML) {
24✔
120
            return;
1✔
121
        }
122

123
        this._syncChildren(target, newNode);
23✔
124
    }
125

126
    /**
127
     * Recursively sync children of an existing element with those of a new element.
128
     * Uses two-tier matching:
129
     *   1. Exact match (node key) — reuse the existing DOM node as-is.
130
     *   2. Shallow match (same tag + id) — keep the outer element, recurse into children.
131
     *   3. No match — clone the new node.
132
     * This ensures unchanged nested scripts are never re-executed, even when an
133
     * ancestor element has changed.
134
     * @param {Node} target - Existing DOM element whose children will be updated
135
     * @param {Node} source - New DOM element whose children are the desired state
136
     * @private
137
     */
138
    _syncChildren(target, source) {
139
        const newChildren = source.childNodes ? Array.from(source.childNodes) : [];
33!
140
        const existingChildren = Array.from(target.childNodes);
33✔
141

142
        // Build exact-key index
143
        const existingByExactKey = new Map();
33✔
144

145
        existingChildren.forEach((child, i) => {
33✔
146
            const key = this._nodeKey(child);
30✔
147

148
            if (!existingByExactKey.has(key)) {
30✔
149
                existingByExactKey.set(key, []);
27✔
150
            }
151
            existingByExactKey.get(key).push(i);
30✔
152
        });
153

154
        // Build shallow-key index (tag + id only, for element nodes)
155
        const existingByShallowKey = new Map();
33✔
156

157
        existingChildren.forEach((child, i) => {
33✔
158
            if (child.nodeType === Node.ELEMENT_NODE) {
30✔
159
                const key = this._shallowKey(child);
21✔
160

161
                if (!existingByShallowKey.has(key)) {
21✔
162
                    existingByShallowKey.set(key, []);
19✔
163
                }
164
                existingByShallowKey.get(key).push(i);
21✔
165
            }
166
        });
167

168
        const usedIndices = new Set();
33✔
169

170
        const resolvedChildren = newChildren.map(newChild => {
33✔
171
            // Tier 1: exact match — reuse as-is
172
            const exactKey = this._nodeKey(newChild);
54✔
173
            const exactCandidates = existingByExactKey.get(exactKey);
54✔
174

175
            if (exactCandidates) {
54✔
176
                for (let j = 0; j < exactCandidates.length; j++) {
7✔
177
                    const idx = exactCandidates[j];
7✔
178

179
                    if (!usedIndices.has(idx)) {
7!
180
                        usedIndices.add(idx);
7✔
181
                        exactCandidates.splice(j, 1);
7✔
182
                        return existingChildren[idx];
7✔
183
                    }
184
                }
185
            }
186

187
            // Tier 2: shallow match (element nodes only, except <script>) — sync attributes & recurse.
188
            // Scripts are excluded: a changed script must be cloned so the browser re-executes it.
189
            if (newChild.nodeType === Node.ELEMENT_NODE && newChild.nodeName !== 'SCRIPT') {
47✔
190
                const shallowKey = this._shallowKey(newChild);
30✔
191
                const shallowCandidates = existingByShallowKey.get(shallowKey);
30✔
192

193
                if (shallowCandidates) {
30✔
194
                    for (let j = 0; j < shallowCandidates.length; j++) {
10✔
195
                        const idx = shallowCandidates[j];
10✔
196

197
                        if (!usedIndices.has(idx)) {
10!
198
                            usedIndices.add(idx);
10✔
199
                            shallowCandidates.splice(j, 1);
10✔
200
                            const existingEl = existingChildren[idx];
10✔
201

202
                            this._syncAttributes(existingEl, newChild);
10✔
203
                            this._syncChildren(existingEl, newChild);
10✔
204
                            return existingEl;
10✔
205
                        }
206
                    }
207
                }
208
            }
209

210
            // Tier 3: no match — clone
211
            return newChild.cloneNode(true);
37✔
212
        });
213

214
        // Positional patching: only mutate nodes that are out of position.
215
        // Nodes already in the correct spot are skipped (zero DOM mutations when unchanged).
216
        let cursor = target.firstChild;
33✔
217

218
        for (const child of resolvedChildren) {
33✔
219
            if (child === cursor) {
54✔
220
                // Already in the right position — advance
221
                cursor = cursor.nextSibling;
15✔
222
            } else {
223
                // Insert/move before cursor (moves existing nodes without re-execution)
224
                target.insertBefore(child, cursor);
39✔
225
            }
226
        }
227

228
        // Remove leftover existing nodes that weren't matched.
229
        // External scripts (with src) are preserved — removing them from the DOM
230
        // doesn't unload their code but can break DOM queries that look for them.
231
        // Inline scripts (no src) are removed since they are one-shot executables.
232
        while (cursor) {
33✔
233
            const next = cursor.nextSibling;
13✔
234

235
            if (cursor.nodeName !== 'SCRIPT' || !cursor.getAttribute('src')) {
13!
236
                target.removeChild(cursor);
13✔
237
            }
238
            cursor = next;
13✔
239
        }
240
    }
241

242
    /**
243
     * Sync attributes from source element to target element.
244
     * Adds/updates attributes present on source, removes those absent from source.
245
     * @param {Element} target
246
     * @param {Element} source
247
     * @private
248
     */
249
    _syncAttributes(target, source) {
250
        const newAttrs = new Set();
10✔
251

252
        for (const attr of source.attributes) {
10✔
253
            newAttrs.add(attr.name);
10✔
254
            if (target.getAttribute(attr.name) !== attr.value) {
10✔
255
                target.setAttribute(attr.name, attr.value);
4✔
256
            }
257
        }
258

259
        // Remove attributes not present in source
260
        const toRemove = [];
10✔
261

262
        for (const attr of target.attributes) {
10✔
263
            if (!newAttrs.has(attr.name)) {
11✔
264
                toRemove.push(attr.name);
1✔
265
            }
266
        }
267
        toRemove.forEach(name => target.removeAttribute(name));
10✔
268
    }
269

270
    /**
271
     * Convert object representation back to DOM node
272
     * @param {Object} obj - Object representation
273
     * @returns {Node} DOM node
274
     * @private
275
     */
276
    _objToNode(obj) {
277
        if (!obj) {
49!
NEW
278
            return null;
×
279
        }
280

281
        if (obj.nodeName === '#text') {
49✔
282
            return document.createTextNode(obj.data || '');
14!
283
        }
284

285
        if (obj.nodeName === '#comment') {
35✔
286
            return document.createComment(obj.data || '');
2!
287
        }
288

289
        return this._objToElement(obj);
33✔
290
    }
291

292
    /**
293
     * Convert an element object representation to a DOM element.
294
     * @param {Object} obj - Object with nodeName, attributes, childNodes
295
     * @returns {Element}
296
     * @private
297
     */
298
    _objToElement(obj) {
299
        const node = document.createElement(obj.nodeName);
33✔
300

301
        if (obj.attributes) {
33!
302
            for (const [name, value] of Object.entries(obj.attributes)) {
33✔
303
                node.setAttribute(name, value);
16✔
304
            }
305
        }
306

307
        if (obj.childNodes) {
33!
308
            for (const child of obj.childNodes) {
33✔
309
                const childNode = this._objToNode(child);
37✔
310

311
                if (childNode) {
37!
312
                    node.appendChild(childNode);
37✔
313
                }
314
            }
315
        }
316

317
        return node;
33✔
318
    }
319

320
    /**
321
     * Generate a string key for a DOM node for fast equality lookup.
322
     * For leaf elements (no child elements), builds a key from tag + attributes + textContent
323
     * to avoid expensive outerHTML serialization. Falls back to outerHTML for nested elements.
324
     * @param {Node} node
325
     * @returns {string}
326
     * @private
327
     */
328
    _nodeKey(node) {
329
        if (node.nodeType === Node.TEXT_NODE) {
84✔
330
            return '#t:' + node.data;
23✔
331
        }
332
        if (node.nodeType === Node.ELEMENT_NODE) {
61✔
333
            // Leaf elements (no child elements): build key from properties directly
334
            if (!node.firstElementChild) {
59✔
335
                let key = node.nodeName;
44✔
336

337
                for (const attr of node.attributes) {
44✔
338
                    key += '\0' + attr.name + '=' + attr.value;
19✔
339
                }
340
                // Include text content for script/style/title etc.
341
                if (node.firstChild) {
44✔
342
                    key += '\0' + node.textContent;
35✔
343
                }
344
                return key;
44✔
345
            }
346
            // Nested elements: fall back to full serialization
347
            return node.outerHTML;
15✔
348
        }
349
        return '#' + node.nodeType + ':' + (node.data || '');
2!
350
    }
351

352
    /**
353
     * Generate a shallow key for an element node: tag name (+ id if present).
354
     * Does NOT include children or other attributes, so two elements with the
355
     * same tag (and id) but different content/attributes produce the same key.
356
     * The id is included to prevent unrelated elements of the same tag from
357
     * being incorrectly paired.
358
     * @param {Node} node - Must be an ELEMENT_NODE
359
     * @returns {string}
360
     * @private
361
     */
362
    _shallowKey(node) {
363
        const id = node.getAttribute('id');
51✔
364

365
        return id ? node.nodeName + '#' + id : node.nodeName;
51✔
366
    }
367
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc