0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Why a JavaScript Serialization Round Trip Can Still Be a Broken Clone

0
Last updated at Posted at 2026-10-23

A clone needs more than valid JSON

Suppose a restored object has the expected methods and updating its counter leaves the original counter unchanged. Is it an independent clone? Not if appending an event also changes the original object's event array.

There is a second boundary before that test can even run: a serializer that writes constructor.name as a type identifier may stop decoding after minification. The JSON remains valid, but the encoder emits a name the decoder no longer recognizes.

Both failures arise when a serialization round trip is treated as sufficient evidence for a cloning contract. A useful test must establish what the round trip preserves inside the new graph and what it separates from the original. This article constructs a small graph serializer to make those obligations observable. It uses two node types, a cycle, a repeated reference, and a mutable array. There is no application-specific workflow to understand.

Build a graph that can expose the failure

The complete runnable example is in examples/graph.cjs; examples/reproduce.cjs exercises it. These are new explanatory experiments. They are not extracted production code.

class RecordNode {
  constructor(id) { this.id = id; this.links = []; this.events = []; }
  connect(node) { this.links.push(node); }
  record(message) { this.events.push(message); }
}
class CounterNode extends RecordNode {
  constructor(id) { super(id); this.value = 0; }
  increment() { this.value += 1; this.record('increment'); }
}

Construct a root and one counter. The root references the same counter twice, and the counter points back to the root:

const root = new RecordNode('root');
const counter = new CounterNode('counter');
root.connect(counter);
root.connect(counter);
counter.connect(root);

This is deliberately more demanding than a tree. A recursive property copier can accidentally create two counters from the repeated reference. Direct JSON stringification of this live graph fails on the cycle. Replacing every instance with a plain object loses increment(). A solution must preserve aliases and cycles while reconstructing the required behavior.

The snapshot therefore stores each object once and represents edges by IDs. Its top-level shape is { root, nodes }. Each node record contains an id, type, optional value, links containing IDs, and events containing strings. Unique IDs and string-only events are preconditions of this restricted example.

Observe the constructor-name failure under a real transform

The intentionally fragile encoder obtains its tag this way:

const type = node.constructor.name;

The matching decoder has a fixed vocabulary:

const legacy = new Map([
  ['RecordNode', RecordNode],
  ['CounterNode', CounterNode],
]);

Without mangling, the strings match. A minifier can rename the first class to e; the encoder now writes e, but the decoder still expects RecordNode. The sample rejects this mismatch with Unknown type: e. It fails at decoding rather than allowing an invalid object to travel farther through the program.

Use Node.js 22.22.0 for the tested environment. Place the companion files graph.cjs and reproduce.cjs in a separate directory named serialization-clone-demo. Next.js 15.2.9 supplies the SWC minifier; the example does not start a Next.js application. From the parent directory, run:

cd serialization-clone-demo
npm init -y
npm install --save-exact next@15.2.9
node reproduce.cjs

The harness loads the original CommonJS module and two transformed variants: top-level mangling enabled, then mangling with class and function names preserved. It uses the SWC minifier shipped with the pinned Next.js version, with compression disabled to isolate renaming. Each variant is evaluated as a fresh module with its own constructors and registry. No application module is imported, and no installed dependency is rewritten. This minifier entry point is internal, so its availability should be rechecked when changing versions.

The SWC minification options include name-preservation controls. This experiment tests those controls on one generic module. It is not a complete Next.js build and does not reproduce an entire deployment pipeline.

Use a shared vocabulary rather than runtime spelling

When the serializer is under your control, define explicit protocol tags and derive the reverse registry from the same mapping:

const tags = new Map([
  [RecordNode, 'record/v1'],
  [CounterNode, 'counter/v1'],
]);
const constructors = new Map(
  [...tags].map(([ctor, tag]) => [tag, ctor])
);

The encoder uses tags.get(node.constructor). Renaming a constructor does not change its object identity, so the lookup survives the tested transform. The decoder resolves a tag through constructors. It never derives a constructor by evaluating data or looking up arbitrary global names.

An unregistered constructor must fail instead of falling back to constructor.name; otherwise the stable contract has an unstable escape hatch. A subclass also needs an explicit registry entry because its constructor is a different object. The /v1 suffix identifies a format vocabulary; it does not implement migration or promise compatibility with older snapshots.

When a third-party serializer cannot be changed, an integration may instead pin the constructor names expected by its decoder. An inspected adapter for this application uses that approach. Such pinning is a version-specific workaround; it does not provide the explicit protocol registry demonstrated here.

Restore identities before reconnecting edges

The encoder visits each node object once, recording links as IDs. The decoder takes two passes. First, it allocates one instance per ID and restores scalar values. Then it resolves every edge against the completed ID map:

for (const item of snapshot.nodes) {
  const Ctor = constructors.get(item.type);
  if (!Ctor) throw new Error('Unknown type: ' + item.type);
  if (byId.has(item.id)) throw new Error('Duplicate id: ' + item.id);
  const node = new Ctor(item.id);
  if (node instanceof CounterNode) node.value = item.value;
  node.events = [...item.events];
  byId.set(item.id, node);
}
for (const item of snapshot.nodes) {
  byId.get(item.id).links = item.links.map(id => {
    if (!byId.has(id)) throw new Error('Missing target: ' + id);
    return byId.get(id);
  });
}

Every edge to the same ID now resolves to the same newly allocated instance. Backward edges work because allocation finishes before linking begins. Snapshot ordering does not need to match traversal order. Duplicate IDs and missing targets are rejected instead of quietly creating ambiguous identities or undefined edges.

The tests assert both clone.links[0] === clone.links[1] and clone.links[0].links[0] === clone. They also check that the restored counter is a CounterNode and differs from the original instance. These are semantic invariants, independent of the minifier's chosen short names.

Put the copy boundary around mutable values

Recreating node instances does not automatically copy their arrays. The deliberately broken path assigns events directly during both encoding and decoding. A restored counter then has a new numeric value but shares its events with the original:

const aliased = decode(encode(root, true, false), true, false);
aliased.links[0].increment();
assert.deepEqual(counter.events, ['increment']);
assert.equal(counter.value, 0);

The boolean arguments enable the harness's broken variants; application code would normally expose only the intended contract. Stable tags do nothing to prevent this alias.

The selected implementation copies the string array at both boundaries: encoding uses [...node.events], and decoding uses [...item.events]. Copying only during encoding separates the original from the snapshot but still lets a later snapshot mutation reach the restored object. Copying only during decoding leaves the snapshot itself dependent on the live source. Both copies make the snapshot and restored graph independently owned for these fields.

A shallow array copy is enough here because entries are strings. Nested mutable event objects would require a different representation or an explicit recursive copying policy. This example does not claim a universal deep clone.

Compare the practical alternatives

Keeping names in the minifier is a plausible compatibility measure when you control all relevant transforms. It preserves the fragile vocabulary without redesigning it, but does not address mutable aliases. Constructor-name pinning similarly depends on shared runtime objects and a known decoder version.

A JSON text boundary around the encoded snapshot can detach JSON-representable arrays. It cannot stringify the original cyclic graph directly, restore prototypes by itself, or repair an unknown type tag. It also introduces parsing and allocation work; no performance comparison is measured here.

The structured clone algorithm supports cycles and many built-in values, but does not preserve arbitrary user-defined prototype chains. For a graph whose methods are part of the contract, reconstruction is still necessary. For plain data with supported built-in types, structured cloning may remove the need for a custom serializer entirely.

The explicit registry and two-pass decoder are chosen here because the example owns its schema and needs class behavior, repeated references, cycles, and defined array ownership. That choice is appropriate to this restricted model, not a recommendation to build a general serialization framework for every object copy.

Verify the operation, not just the round trip

The results table is generated from the companion's actual output. The run records Node, Next.js, and TypeScript versions in verification.json.

Transform Runtime class name Name-based decoding Explicit tag Graph and isolation checks
baseline RecordNode passes record/v1 pass
swc-mangled e Unknown type: e record/v1 pass
swc-keep-names RecordNode passes record/v1 pass

In all three variants, the tests reproduce shared-array mutation with copying disabled. With stable tags and array copying enabled, they preserve the repeated reference and cycle, execute increment(), and assert that the original encoded snapshot stays unchanged. Further mutations of the snapshot and original arrays do not reach the clone. Negative tests reject unknown tags, duplicate IDs, and dangling edges.

These checks cover a controlled graph with two registered types. They do not validate arbitrary hostile input, impose resource limits, handle arbitrary built-ins, test a browser bundle, or prove historical format compatibility. Persistent-format support would need immutable fixtures from known previous writers and explicit migration tests. This example makes no such compatibility claim.

A successful decode is only the start of a clone test. Check the identities that must remain shared inside the new graph, the mutable values that must stop being shared across its boundary, and an operation that would reveal a violation. Those assertions remain useful even if the serializer, framework, or build tool changes.

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?