Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/small-promises-resolve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'devalue': patch
---

Fix special values resolved by nested promises in `stringifyAsync`.
75 changes: 48 additions & 27 deletions src/stringify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(',')}]`;
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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':
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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 += ']';
Expand All @@ -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();
}
Expand All @@ -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) {
Expand All @@ -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}`;
Expand Down Expand Up @@ -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 += ']';
Expand All @@ -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 += '}';
Expand Down
119 changes: 118 additions & 1 deletion test/stringify-async.test.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down