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