From 9895d247938e4d4f3c6abdfe2e0f0daebe0426a6 Mon Sep 17 00:00:00 2001 From: Luca Casonato Date: Wed, 3 Jun 2026 12:00:14 +0200 Subject: [PATCH 1/2] Identity de-dupe with replacers in uneval This commit implements a mechanism to enable reducers in `uneval` to not duplicate values that are referenced multiple times. This fix involves sometimes calling `replacer` twice for the same value. Right now it also calls replacer twice for the same value when we can know to avoid it, but the bookkeeping required to avoid it likely not worth the memory complexity it adds. --- src/uneval.js | 41 ++++++++++++++++++++++++++++++++++++----- test/index.test.js | 17 +++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/uneval.js b/src/uneval.js index 5c1f39e..d70f7bb 100644 --- a/src/uneval.js +++ b/src/uneval.js @@ -26,6 +26,7 @@ export function uneval(value, replacer) { /** @type {string[]} */ const keys = []; + /** @type {Map} */ const custom = new Map(); /** @param {any} thing */ @@ -39,10 +40,18 @@ export function uneval(value, replacer) { counts.set(thing, 1); if (replacer) { - const str = replacer(thing, (value) => uneval(value, replacer)); + let calledWalk = false; + const str = replacer(thing, (value) => { + calledWalk = true; + walk(value); + return ''; + }); if (typeof str === 'string') { - custom.set(thing, str); + // If the replacer called walk, then we need to handle the possiblilty that inner values + // are seen multiple times, and may need to be deduped with a name. In that case run the + // replacer again during stringify. + custom.set(thing, calledWalk ? undefined : str); return; } } @@ -167,7 +176,18 @@ export function uneval(value, replacer) { } if (custom.has(thing)) { - return custom.get(thing); + const str = custom.get(thing); + if (str !== undefined) { + return str; + } + const finalStr = replacer(thing, (value) => stringify(value)); + if (typeof finalStr === 'string') { + custom.set(thing, finalStr); + return finalStr; + } + throw new TypeError( + 'Replacer must return a string when called on a value it previously returned a string for' + ); } const type = get_type(thing); @@ -367,8 +387,19 @@ export function uneval(value, replacer) { params.push(name); if (custom.has(thing)) { - values.push(/** @type {string} */ (custom.get(thing))); - return; + const str = custom.get(thing); + if (str !== undefined) { + values.push(str); + return; + } + const finalStr = replacer(thing, (value) => stringify(value)); + if (typeof finalStr === 'string') { + values.push(finalStr); + return; + } + throw new TypeError( + 'Replacer must return a string when called on a value it previously returned a string for' + ); } if (is_primitive(thing)) { diff --git a/test/index.test.js b/test/index.test.js index a80125a..1c7a448 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1350,6 +1350,23 @@ uvu.test('does not create duplicate parameter names', () => { eval(serialized); }); +uvu.test('replacer callback preserves shared references', () => { + const shared = { answer: 42 }; + const value = [new Foo(shared), shared]; + + const serialized = uneval(value, (value, uneval) => { + if (value instanceof Foo) { + return `new Foo(${uneval(value.value)})`; + } + }); + + const roundtripped = eval(serialized); + + assert.ok(roundtripped[0] instanceof Foo); + assert.is(roundtripped[0].value, roundtripped[1]); + assert.is(roundtripped[1].answer, 42); +}); + uvu.test('rejects sparse array __proto__ pollution via parse', () => { // Attempt to set __proto__ on an array via the sparse array encoding const payload = JSON.stringify([[consts.SPARSE, 1, '__proto__', { polluted: true }]]); From 1702212c72de15df77e019e35c9c53832916b126 Mon Sep 17 00:00:00 2001 From: Luca Casonato Date: Wed, 3 Jun 2026 12:06:42 +0200 Subject: [PATCH 2/2] changeset --- .changeset/fix-uneval-replacer-dedupe.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/fix-uneval-replacer-dedupe.md diff --git a/.changeset/fix-uneval-replacer-dedupe.md b/.changeset/fix-uneval-replacer-dedupe.md new file mode 100644 index 0000000..c3c329e --- /dev/null +++ b/.changeset/fix-uneval-replacer-dedupe.md @@ -0,0 +1,9 @@ +--- +'devalue': patch +--- + +fix: preserve shared-reference deduping when a custom `uneval` replacer calls +the callback for nested values + +This can cause replacers to run twice for the same value, if the replacer calls +the uneval callback passed to it.