Today, while investigating a bug in our app, I witnessed a very surprising behavior in JavaScript's structuredClone.
This method promises to create a deep clone of a given value. This was previously achieved using the JSON.parse(JSON.stringify(value)) technique, and on paper structuredClone appears to be a superset per se of this technique, yielding the same result, while also supporting things like Dates and circular references.
However, today I learned that if you use structuredClone to clone an object containing reference type variables pointing to the same reference, these references will be kept, as opposed to creating new values with different references.
Here is a toy example to demonstrate this behavior:
const someSharedArray = ['foo', 'bar']
const myObj = {
field1: someSharedArray,
field2: someSharedArray,
field3: someSharedArray,
}
const myObjCloned = structuredClone(myObj)
console.log(myObjCloned)
/**
{
"field1": ["foo", "bar"],
"field2": ["foo", "bar"],
"field3": ["foo", "bar"],
}
**/
myObjCloned.field2[1] = 'baz'
// At this point:
// Expected: only `field2`'s value should change, because `myObjCloned` was deeply cloned.
// Actual: all fields' values change, because they all still point to `someSharedArray`
console.log(myObjCloned)
/**
{
"field1": ["foo", "baz"],
"field2": ["foo", "baz"],
"field3": ["foo", "baz"],
}
**/
This is a very surprising behavior of structuredClone, because:
JSON.parse(JSON.stringify(value))not truly deep copy?
It is a deep copy.
A proper deep copy should adhere to a few conditions:
It should map every distinct object in the original to exactly one distinct object in the result: a 1-to-1 mapping. This is also the guiding principle that ensures that circular references are supported.
If two distinct properties have identical values (Object.is(a, b) === true), then these properties in the deep clone should also be identical to each other.
In your example input there are two distinct objects: one array, and one (top-level) complex object. Furthermore, the result of Object.is(myObj.field1, myObj.field2) is true.
What you get with structuredClone in your example adheres to this. Notibly, Object.is(myObjCloned.field1, myObjCloned.field2) is true.
What you expected to get (and what JSON.parse(JSON.stringify(value)) returns) violates this principle: three distinct arrays would be created, which means the same array has been copied more than once, and there is no 1-to-1 mapping anymore. The previously mentioned Object.is expression evaluates to false.
Let's take an input with a back reference:
const root = {};
root.arr = [root, root, root];
Here we have one object and one array. That latter holds three references to the first object. Also here we expect these three references to one object to result in another trio of references, each referencing the one-and-only clone-parent object. This is the same principle as what happens in your example, just that the shared reference happens to be a parent object.
// Step 1: Create a shared array
// ---------------------------------------------
const someSharedArray = ['foo', 'bar'];
// Suppose this array is stored at memory address 0x1
// Step 2: Create an object where every field
// references the SAME array (0x1)
// ---------------------------------------------
const myObj = {
field1: someSharedArray, // → 0x1
field2: someSharedArray, // → 0x1
field3: someSharedArray, // → 0x1
};
// The object itself (myObj) is at address 0x0
console.log('Original object (myObj):', myObj);
/*
myObj (0x0):
field1 → 0x1 ['foo', 'bar']
field2 → 0x1 ['foo', 'bar']
field3 → 0x1 ['foo', 'bar']
*/
// Step 3: Deep clone the object using structuredClone()
// ---------------------------------------------
const myObjCloned = structuredClone(myObj);
// structuredClone() creates a deep copy of the structure.
// It notices that field1, field2, and field3 all point to the same array (0x1).
// Therefore, it creates ONE new array (at 0x3) and makes all fields
// in the clone point to that same new array.
console.log('After structuredClone():', myObjCloned);
/*
myObjCloned (0x2):
field1 → 0x3 ['foo', 'bar']
field2 → 0x3 ['foo', 'bar']
field3 → 0x3 ['foo', 'bar']
Notes:
- The cloned object itself is at address 0x2.
- It has a new array (0x3), not the same as the original (0x1).
- But within the clone, all fields share that same new array.
*/
// Step 4: Modify one field’s array in the clone
// ---------------------------------------------
myObjCloned.field2[1] = 'baz';
// This modifies the array stored at address 0x3.
console.log('After modifying myObjCloned.field2:', myObjCloned);
/*
myObjCloned (0x2):
field1 → 0x3 ['foo', 'baz'] (changed)
field2 → 0x3 ['foo', 'baz'] (changed)
field3 → 0x3 ['foo', 'baz'] (changed)
*/
// Step 5: Check if the original object was affected
// ---------------------------------------------
console.log('Original (myObj):', myObj);
/*
myObj (0x0):
field1 → 0x1 ['foo', 'bar'] (unaffected)
field2 → 0x1 ['foo', 'bar']
field3 → 0x1 ['foo', 'bar']
*/
console.log('Are cloned and original arrays the same?',
myObj.field1 === myObjCloned.field1); // false (0x1 ≠ 0x3)
console.log('Are cloned fields internally the same?',
myObjCloned.field1 === myObjCloned.field2); // true (both point to 0x3)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With