diff --git a/lib/core/utils/clone.js b/lib/core/utils/clone.js index 45d0b17e03..ee4802fc6c 100644 --- a/lib/core/utils/clone.js +++ b/lib/core/utils/clone.js @@ -1,35 +1,47 @@ /** - * Deeply clones an object or array + * Deeply clones an object or array. DOM nodes or collections of DOM nodes are not deeply cloned and are instead returned as is. * @param {Mixed} obj The object/array to clone - * @return {Mixed} A clone of the initial object or array + * @return {Mixed} A clone of the initial object or array */ -function clone(obj) { - /* eslint guard-for-in: 0*/ - var index, - length, - out = obj; - // DOM nodes cannot be cloned. +export default function clone(obj) { + return cloneRecused(obj, new Map()); +} + +// internal function to hide non-user facing parameters +function cloneRecused(obj, seen) { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + // don't clone DOM nodes. since we can pass nodes from different window contexts + // we'll also use duck typing to determine what is a DOM node if ( (window?.Node && obj instanceof window.Node) || - (window?.HTMLCollection && obj instanceof window.HTMLCollection) + (window?.HTMLCollection && obj instanceof window.HTMLCollection) || + ('nodeName' in obj && 'nodeType' in obj && 'ownerDocument' in obj) ) { return obj; } - if (obj !== null && typeof obj === 'object') { - if (Array.isArray(obj)) { - out = []; - for (index = 0, length = obj.length; index < length; index++) { - out[index] = clone(obj[index]); - } - } else { - out = {}; - for (index in obj) { - out[index] = clone(obj[index]); - } - } + // handle circular references by caching the cloned object and returning it + if (seen.has(obj)) { + return seen.get(obj); + } + + if (Array.isArray(obj)) { + const out = []; + seen.set(obj, out); + obj.forEach(value => { + out.push(cloneRecused(value, seen)); + }); + return out; + } + + const out = {}; + seen.set(obj, out); + // eslint-disable-next-line guard-for-in + for (const key in obj) { + out[key] = cloneRecused(obj[key], seen); } return out; } - -export default clone; diff --git a/test/core/utils/clone.js b/test/core/utils/clone.js index 583f9bb9fe..45cc86fe76 100644 --- a/test/core/utils/clone.js +++ b/test/core/utils/clone.js @@ -1,26 +1,26 @@ -describe('utils.clone', function () { - 'use strict'; - var clone = axe.utils.clone; +describe('utils.clone', () => { + const clone = axe.utils.clone; + const fixture = document.querySelector('#fixture'); - it('should clone an object', function () { - var obj = { + it('should clone an object', () => { + const obj = { cats: true, dogs: 2, - fish: [0, 1, 2] + fish: [0, 1, { one: 'two' }] }; - var c = clone(obj); + const c = clone(obj); obj.cats = false; obj.dogs = 1; - obj.fish[0] = 'stuff'; + obj.fish[2].one = 'three'; assert.strictEqual(c.cats, true); assert.strictEqual(c.dogs, 2); - assert.deepEqual(c.fish, [0, 1, 2]); + assert.deepEqual(c.fish, [0, 1, { one: 'two' }]); }); - it('should clone nested objects', function () { - var obj = { + it('should clone nested objects', () => { + const obj = { cats: { fred: 1, billy: 2, @@ -33,7 +33,7 @@ describe('utils.clone', function () { }, fish: [0, 1, 2] }; - var c = clone(obj); + const c = clone(obj); obj.cats.fred = 47; obj.dogs = 47; @@ -54,45 +54,107 @@ describe('utils.clone', function () { assert.deepEqual(c.fish, [0, 1, 2]); }); - it('should clone objects with methods', function () { - var obj = { - cats: function () { + it('should clone objects with methods', () => { + const obj = { + cats: () => { return 'meow'; }, - dogs: function () { + dogs: () => { return 'woof'; } }; - var c = clone(obj); + const c = clone(obj); assert.strictEqual(obj.cats, c.cats); assert.strictEqual(obj.dogs, c.dogs); - obj.cats = function () {}; - obj.dogs = function () {}; + obj.cats = () => {}; + obj.dogs = () => {}; assert.notStrictEqual(obj.cats, c.cats); assert.notStrictEqual(obj.dogs, c.dogs); }); - it('should clone prototypes', function () { + it('should clone prototypes', () => { function Cat(name) { this.name = name; } - Cat.prototype.meow = function () { + Cat.prototype.meow = () => { return 'meow'; }; - Cat.prototype.bark = function () { + Cat.prototype.bark = () => { return 'cats dont bark'; }; - var cat = new Cat('Fred'), + const cat = new Cat('Fred'), c = clone(cat); assert.deepEqual(cat.name, c.name); assert.deepEqual(Cat.prototype.bark, c.bark); assert.deepEqual(Cat.prototype.meow, c.meow); }); + + it('should clone circular objects while keeping the circular reference', () => { + const obj = { cats: true }; + obj.child = obj; + const c = clone(obj); + + obj.cats = false; + + assert.deepEqual(c, { + cats: true, + child: c + }); + assert.strictEqual(c, c.child); + }); + + it('should not return the same object when cloned twice', () => { + const obj = { cats: true }; + const c1 = clone(obj); + const c2 = clone(obj); + + assert.notStrictEqual(c1, c2); + }); + + it('should not return the same object when nested', () => { + const obj = { dogs: true }; + const obj1 = { cats: true, child: { prop: obj } }; + const obj2 = { fish: [0, 1, 2], child: { prop: obj } }; + + const c1 = clone(obj1); + const c2 = clone(obj2); + + assert.notStrictEqual(c1.child.prop, c2.child.prop); + }); + + it('should not clone HTML elements', () => { + const obj = { + cats: true, + node: document.createElement('div') + }; + const c = clone(obj); + + obj.cats = false; + + assert.equal(c.cats, true); + assert.strictEqual(c.node, obj.node); + }); + + it('should not clone HTML elements from different windows', () => { + fixture.innerHTML = ''; + const iframe = fixture.querySelector('#target'); + + const obj = { + cats: true, + node: iframe.contentDocument + }; + const c = clone(obj); + + obj.cats = false; + + assert.equal(c.cats, true); + assert.strictEqual(c.node, obj.node); + }); });