From a92297b6f8b1040cd6cf9243a3f4e7eecd9ff2b8 Mon Sep 17 00:00:00 2001 From: Mayank Dhingra Date: Wed, 14 Oct 2026 19:19:50 -0400 Subject: [PATCH] fix: preserve special values resolved by nested promises Resolve promise references after asynchronous serialization so nested special values retain their sentinel meaning. Remap references after omitting aliases, including promises that resolve to an existing value. Escape custom reducer names before emitting internal reference tokens. Cover container types, repeated references, typed-array metadata, and reducer names with regression tests. Signed-off-by: Mayank Dhingra --- .changeset/small-promises-resolve.md | 5 ++ src/stringify.js | 75 +++++++++++------ test/stringify-async.test.js | 119 ++++++++++++++++++++++++++- 3 files changed, 171 insertions(+), 28 deletions(-) create mode 100644 .changeset/small-promises-resolve.md diff --git a/.changeset/small-promises-resolve.md b/.changeset/small-promises-resolve.md new file mode 100644 index 00000000..c6fe7112 --- /dev/null +++ b/.changeset/small-promises-resolve.md @@ -0,0 +1,5 @@ +--- +'devalue': patch +--- + +Fix special values resolved by nested promises in `stringifyAsync`. diff --git a/src/stringify.js b/src/stringify.js index 9d3a6b53..667ef338 100644 --- a/src/stringify.js +++ b/src/stringify.js @@ -35,30 +35,43 @@ export async function stringifyAsync(value, reducers, options) { return stringified; } - let out = '['; - for (let i = 0; i < stringified.length; i += 1) { - let value = stringified[i]; - - if (typeof value !== 'string') { - await value; - value = stringified[i]; + if (typeof stringified[i] !== 'string') { + await stringified[i]; + } + } - if (i === 0 && value < 0) { - return `${value}`; - } + /** @param {number} index */ + function resolve_index(index) { + while (index >= 0 && typeof stringified[index] === 'number') { + index = stringified[index]; } + return index; + } - out += value; + const root = resolve_index(0); + if (root < 0) return `${root}`; - if (i < stringified.length - 1) { - out += ','; + // Resolved promises can alias a sentinel or an existing value. Omit those + // slots and remap references only after every promise has settled. + const indexes = new Map(); + /** @type {string[]} */ + const values = []; + for (let i = 0; i < stringified.length; i += 1) { + if (typeof stringified[i] === 'string') { + indexes.set(i, values.length); + values.push(stringified[i]); } } - out += ']'; - - return out; + return `[${values + .map((value) => + value.replace(/\0(-?\d+)\0/g, (_, index) => { + const resolved = resolve_index(+index); + return String(resolved < 0 ? resolved : indexes.get(resolved)); + }) + ) + .join(',')}]`; } /** @@ -91,6 +104,14 @@ function run(async, value, reducers, options) { let p = 0; + /** @param {any} thing */ + function reference(thing) { + const index = flatten(thing); + // JSON strings escape NUL, so these internal tokens cannot collide with + // serialized string values or keys. + return async ? `\0${index}\0` : index; + } + /** * @param {string} message * @param {any} thing @@ -140,7 +161,7 @@ function run(async, value, reducers, options) { for (const { key, fn } of custom) { const value = fn(thing); if (value) { - stringified[index] = `["${key}",${flatten(value)}]`; + stringified[index] = `[${stringify_string(key)},${reference(value)}]`; return index; } } @@ -169,7 +190,7 @@ function run(async, value, reducers, options) { keys = prev_keys; const i = flatten(value, index); - if (i < 0) stringified[index] = i; + if (i !== index) stringified[index] = i; }); // Promises are awaited sequentially, and traversal may throw before @@ -184,7 +205,7 @@ function run(async, value, reducers, options) { case 'String': case 'Boolean': case 'BigInt': - str = `["Object",${flatten(ops.unbox(thing))}]`; + str = `["Object",${reference(ops.unbox(thing))}]`; break; case 'Date': @@ -225,7 +246,7 @@ function run(async, value, reducers, options) { if (ops.hasOwn(thing, i)) { keys.push(i); - str += flatten(ops.get(thing, i)); + str += reference(ops.get(thing, i)); keys.pop(); } else if (mostly_dense) { // Use dense encoding. The heuristic guarantees the @@ -276,7 +297,7 @@ function run(async, value, reducers, options) { for (let j = 0; j < populated_keys.length; j++) { const key = populated_keys[j]; keys.push(+key); - str += ',' + key + ',' + flatten(ops.get(thing, key)); + str += ',' + key + ',' + reference(ops.get(thing, key)); keys.pop(); } break; @@ -296,7 +317,7 @@ function run(async, value, reducers, options) { str = '["Set"'; for (const value of ops.valuesOf(thing)) { - str += `,${flatten(value)}`; + str += `,${reference(value)}`; } str += ']'; @@ -307,7 +328,7 @@ function run(async, value, reducers, options) { for (const [key, value] of ops.entriesOf(thing)) { keys.push(MAP_KEY, key); - str += `,${flatten(key)},${flatten(value)}`; + str += `,${reference(key)},${reference(value)}`; keys.pop(); keys.pop(); } @@ -328,7 +349,7 @@ function run(async, value, reducers, options) { case 'BigInt64Array': case 'BigUint64Array': { const info = ops.viewInfo(thing); - str = '["' + tag + '",' + flatten(info.buffer); + str = '["' + tag + '",' + reference(info.buffer); // handle subarrays if (info.byteLength !== info.bufferByteLength) { @@ -341,7 +362,7 @@ function run(async, value, reducers, options) { case 'DataView': { const info = ops.viewInfo(thing); - str = '["' + tag + '",' + flatten(info.buffer); + str = '["' + tag + '",' + reference(info.buffer); if (info.byteLength !== info.bufferByteLength) { str += `,${info.byteOffset},${info.byteLength}`; @@ -388,7 +409,7 @@ function run(async, value, reducers, options) { } keys.push(key); - str += `,${quote_key(key)},${flatten(ops.get(thing, key))}`; + str += `,${quote_key(key)},${reference(ops.get(thing, key))}`; keys.pop(); } str += ']'; @@ -403,7 +424,7 @@ function run(async, value, reducers, options) { if (started) str += ','; started = true; keys.push(key); - str += `${quote_key(key)}:${flatten(ops.get(thing, key))}`; + str += `${quote_key(key)}:${reference(ops.get(thing, key))}`; keys.pop(); } str += '}'; diff --git a/test/stringify-async.test.js b/test/stringify-async.test.js index 2dd22ef2..40c88b53 100644 --- a/test/stringify-async.test.js +++ b/test/stringify-async.test.js @@ -1,5 +1,122 @@ import { execFileSync } from 'node:child_process'; -import { describe, test } from 'vitest'; +import { describe, expect, test } from 'vitest'; +import { parse, stringify, stringifyAsync, unflatten } from '../src/index.js'; + +const special_values = [ + { name: 'undefined', value: undefined }, + { name: 'NaN', value: NaN }, + { name: 'negative zero', value: -0 }, + { name: 'positive infinity', value: Infinity }, + { name: 'negative infinity', value: -Infinity } +]; + +class Wrapped { + constructor(value) { + this.value = value; + } +} + +describe.each([ + { name: 'objects', wrap: (value) => ({ value }) }, + { name: 'arrays', wrap: (value) => [value] }, + { name: 'Sets', wrap: (value) => new Set([value]) }, + { name: 'Map keys', wrap: (value) => new Map([[value, 'unchanged']]) }, + { name: 'Map values', wrap: (value) => new Map([['unchanged', value]]) }, + { + name: 'null-prototype objects', + wrap: (value) => Object.assign(Object.create(null), { value }) + }, + { + name: 'sparse arrays', + wrap: (value) => { + const array = Array(1000); + array[999] = value; + return array; + } + }, + { + name: 'custom reducer payloads', + wrap: (value) => new Wrapped(value), + reducers: { Wrapped: (value) => value instanceof Wrapped && [value.value] }, + revivers: { Wrapped: ([value]) => new Wrapped(value) } + } +])('stringifyAsync: special values in $name', ({ wrap, reducers, revivers }) => { + test.each(special_values)('resolves $name', async ({ value }) => { + const expected = wrap(value); + const serialized = await stringifyAsync(wrap(Promise.resolve(value)), reducers); + + expect(parse(serialized, revivers)).toEqual(expected); + expect(unflatten(JSON.parse(serialized), revivers)).toEqual(expected); + }); +}); + +describe('stringifyAsync: resolved references', () => { + test.each(special_values)('resolves repeated promises of $name', async ({ value }) => { + const promise = Promise.resolve(value); + const serialized = await stringifyAsync({ value: promise, array: [promise, promise] }); + const expected = { value, array: [value, value] }; + + expect(serialized).toEqual(stringify(expected)); + expect(parse(serialized)).toEqual(expected); + }); + + test('resolves special values discovered inside a resolved promise', async () => { + const serialized = await stringifyAsync({ + value: Promise.resolve({ nested: Promise.resolve(-Infinity) }) + }); + const expected = { value: { nested: -Infinity } }; + + expect(serialized).toEqual(stringify(expected)); + expect(parse(serialized)).toEqual(expected); + }); + + test('preserves ordinary negative numbers and strings containing reference-like text', async () => { + const expected = { + '\u0000-1\u0000': '\u00001\u0000', + values: [-1, -3, -4, -5, -6], + value: undefined + }; + const serialized = await stringifyAsync({ ...expected, value: Promise.resolve(undefined) }); + + expect(serialized).toEqual(stringify(expected)); + expect(parse(serialized)).toEqual(expected); + }); + + test('deduplicates promises resolving to an existing value', async () => { + const value = { nested: true }; + const serialized = await stringifyAsync([value, Promise.resolve(value)]); + const parsed = parse(serialized); + + expect(serialized).toEqual(stringify([value, value])); + expect(parsed[0]).toBe(parsed[1]); + }); + + test('preserves typed-array offsets when resolved promises remove reference slots', async () => { + const buffer = new Uint8Array([0, 1, 2, 3]).buffer; + const view = new Uint8Array(buffer, 1, 2); + const expected = { value: undefined, view }; + const serialized = await stringifyAsync({ value: Promise.resolve(undefined), view }); + const parsed = parse(serialized); + + expect(serialized).toEqual(stringify(expected)); + expect(parsed).toEqual(expected); + expect(parsed.view.byteOffset).toBe(1); + expect(parsed.view.buffer.byteLength).toBe(4); + }); + + test('preserves custom reducer names containing quotes and reference-like text', async () => { + const key = 'Type"\u00001\u0000'; + const reducers = { [key]: (value) => value instanceof Wrapped && [value.value] }; + const revivers = { [key]: ([value]) => new Wrapped(value) }; + const expected = new Wrapped(undefined); + const sync = stringify(expected, reducers); + const async = await stringifyAsync(new Wrapped(Promise.resolve(undefined)), reducers); + + expect(async).toEqual(sync); + expect(parse(sync, revivers)).toEqual(expected); + expect(parse(async, revivers)).toEqual(expected); + }); +}); describe('stringifyAsync: rejection handling', () => { // Run each regression in isolation so an unhandled internal rejection fails the