From 208f72d07ae90b9bfd3939df9ed0a2568d6180a8 Mon Sep 17 00:00:00 2001 From: greymoth <246701683+greymoth-jp@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:26:55 +0900 Subject: [PATCH 1/2] fix: keep -0 and +0 distinct through serialize/deserialize The serialize memo is a Map, whose keys use SameValueZero, so -0 and +0 share a record when both appear in one value and collapse to a single sign after deserialize. Skip the memo for negative zero so it never shares a record with +0; every other value memoizes as before, so reference sharing, cycles and the existing serialized output are unchanged. --- cjs/serialize.js | 11 +++++++++-- esm/serialize.js | 11 +++++++++-- test/index.js | 13 +++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/cjs/serialize.js b/cjs/serialize.js index ac6cec1..f0c361f 100644 --- a/cjs/serialize.js +++ b/cjs/serialize.js @@ -47,14 +47,21 @@ const shouldSkip = ([TYPE, type]) => ( const serializer = (strict, json, $, _) => { + // `$` memoizes seen values to preserve shared references and handle cycles, + // but it is a Map and Map keys use SameValueZero, which treats -0 and +0 as + // the same key. Negative zero must skip the memo so it is never conflated + // with +0, which native structuredClone (and the default clone) keep apart. + const memoizable = value => !Object.is(value, -0); + const as = (out, value) => { const index = _.push(out) - 1; - $.set(value, index); + if (memoizable(value)) + $.set(value, index); return index; }; const pair = value => { - if ($.has(value)) + if (memoizable(value) && $.has(value)) return $.get(value); let [TYPE, type] = typeOf(value); diff --git a/esm/serialize.js b/esm/serialize.js index 24cfe2c..17ce895 100644 --- a/esm/serialize.js +++ b/esm/serialize.js @@ -49,14 +49,21 @@ const shouldSkip = ([TYPE, type]) => ( const serializer = (strict, json, $, _) => { + // `$` memoizes seen values to preserve shared references and handle cycles, + // but it is a Map and Map keys use SameValueZero, which treats -0 and +0 as + // the same key. Negative zero must skip the memo so it is never conflated + // with +0, which native structuredClone (and the default clone) keep apart. + const memoizable = value => !Object.is(value, -0); + const as = (out, value) => { const index = _.push(out) - 1; - $.set(value, index); + if (memoizable(value)) + $.set(value, index); return index; }; const pair = value => { - if ($.has(value)) + if (memoizable(value) && $.has(value)) return $.get(value); let [TYPE, type] = typeOf(value); diff --git a/test/index.js b/test/index.js index 0277401..296761f 100644 --- a/test/index.js +++ b/test/index.js @@ -168,4 +168,17 @@ const invalidViaJSON = parse(stringify(invalidDate)); assert(invalidViaJSON instanceof Date, true); assert(Number.isNaN(invalidViaJSON.getTime()), true); +// -0 and +0 must round-trip distinctly like native structuredClone, even when +// both appear in the same value. The serialize memo is a Map, whose keys use +// SameValueZero, so without care +0 and -0 collapse onto a single record. +const zeros = deserialize(serialize([-0, 0])); +assert(zeros[0], -0, `expected -0 at [0], got ${zeros[0]}`); +assert(zeros[1], 0, `expected +0 at [1], got ${zeros[1]}`); +const zerosRev = deserialize(serialize([0, -0])); +assert(zerosRev[0], 0, `expected +0 at [0], got ${zerosRev[0]}`); +assert(zerosRev[1], -0, `expected -0 at [1], got ${zerosRev[1]}`); +const zerosObj = deserialize(serialize({neg: -0, pos: 0})); +assert(zerosObj.neg, -0, `expected -0 for neg, got ${zerosObj.neg}`); +assert(zerosObj.pos, 0, `expected +0 for pos, got ${zerosObj.pos}`); + require('./eval.js'); From f5f6519f9f4e9a5a92b07d9dbdf474ddcbe7c01c Mon Sep 17 00:00:00 2001 From: greymoth <246701683+greymoth-jp@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:21:23 +0900 Subject: [PATCH 2/2] perf: only run Object.is on zero-valued numbers in the memo guard Guard the negative-zero check so Object.is runs only when the value is a zero-valued number; non-numbers and nonzero numbers short-circuit before it, so the per-value memo cost is unchanged from before this fix. --- cjs/serialize.js | 4 +++- esm/serialize.js | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cjs/serialize.js b/cjs/serialize.js index f0c361f..342ad94 100644 --- a/cjs/serialize.js +++ b/cjs/serialize.js @@ -51,7 +51,9 @@ const serializer = (strict, json, $, _) => { // but it is a Map and Map keys use SameValueZero, which treats -0 and +0 as // the same key. Negative zero must skip the memo so it is never conflated // with +0, which native structuredClone (and the default clone) keep apart. - const memoizable = value => !Object.is(value, -0); + // Gate the Object.is check behind a number/zero test so it only runs for + // zero-valued numbers; every other value short-circuits before it. + const memoizable = value => !(typeof value === "number" && !value && Object.is(value, -0)); const as = (out, value) => { const index = _.push(out) - 1; diff --git a/esm/serialize.js b/esm/serialize.js index 17ce895..034d44b 100644 --- a/esm/serialize.js +++ b/esm/serialize.js @@ -53,7 +53,9 @@ const serializer = (strict, json, $, _) => { // but it is a Map and Map keys use SameValueZero, which treats -0 and +0 as // the same key. Negative zero must skip the memo so it is never conflated // with +0, which native structuredClone (and the default clone) keep apart. - const memoizable = value => !Object.is(value, -0); + // Gate the Object.is check behind a number/zero test so it only runs for + // zero-valued numbers; every other value short-circuits before it. + const memoizable = value => !(typeof value === "number" && !value && Object.is(value, -0)); const as = (out, value) => { const index = _.push(out) - 1;