From e1a4c5d8b0163bd6a9a27b89fb1d8ca27f1b59f0 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 9 Feb 2023 18:09:58 -0500 Subject: [PATCH 1/7] support custom types --- README.md | 31 +++++++++++++++++++++++++++++++ src/parse.js | 23 ++++++++++++++++++----- src/stringify.js | 17 ++++++++++++++++- test/test.js | 31 +++++++++++++++++++++++++++++-- 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 123e9c7..97af01c 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,37 @@ const json = `{ const data = devalue.unflatten(JSON.parse(json).data); ``` +## Custom types + +You can serialize and serialize custom types by passing a second argument to `stringify` containing an object of types and their _reducers_, and a second argument to `parse` or `unflatten` containing an object of types and their _revivers_: + +```js +class Vector { + constructor(x, y) { + this.x = x; + this.y = y; + } + + magnitude() { + return Math.sqrt(this.x * this.x + this.y * this.y); + } +} + +const stringified = devalue.stringify(new Vector(30, 40), { + Vector: (value) => value instanceof Vector && [value.x, value.y] +}); + +console.log(stringified); // [["Vector",1],[2,3],30,40] + +const vector = devalue.parse(stringified, { + Vector: ([x, y]) => new Vector(x, y) +}); + +console.log(vector.magnitude()); // 50 +``` + +If a function passed to `stringify` returns a truthy value, it's treated as a match. + ## Error handling If `uneval` or `stringify` encounters a function or a non-POJO, it will throw an error. You can find where in the input data the offending value lives by inspecting `error.path`: diff --git a/src/parse.js b/src/parse.js index 890bec3..7b319eb 100644 --- a/src/parse.js +++ b/src/parse.js @@ -10,16 +10,18 @@ import { /** * Revive a value serialized with `devalue.stringify` * @param {string} serialized + * @param {Record any>} revivers */ -export function parse(serialized) { - return unflatten(JSON.parse(serialized)); +export function parse(serialized, revivers) { + return unflatten(JSON.parse(serialized), revivers); } /** - * Revive a value flattened with `devalue.flatten` + * Revive a value flattened with `devalue.stringify` * @param {number | any[]} parsed + * @param {Record any>} revivers */ -export function unflatten(parsed) { +export function unflatten(parsed, revivers) { if (typeof parsed === 'number') return hydrate(parsed, true); if (!Array.isArray(parsed) || parsed.length === 0) { @@ -30,7 +32,10 @@ export function unflatten(parsed) { const hydrated = Array(values.length); - /** @param {number} index */ + /** + * @param {number} index + * @returns {any} + */ function hydrate(index, standalone = false) { if (index === UNDEFINED) return undefined; if (index === NAN) return NaN; @@ -50,6 +55,11 @@ export function unflatten(parsed) { if (typeof value[0] === 'string') { const type = value[0]; + const reviver = revivers?.[type]; + if (reviver) { + return (hydrated[index] = reviver(hydrate(value[1]))); + } + switch (type) { case 'Date': hydrated[index] = new Date(value[1]); @@ -90,6 +100,9 @@ export function unflatten(parsed) { obj[value[i]] = hydrate(value[i + 1]); } break; + + default: + throw new Error(`Unknown type ${type}`); } } else { const array = new Array(value.length); diff --git a/src/stringify.js b/src/stringify.js index 13adb5b..eb8dae4 100644 --- a/src/stringify.js +++ b/src/stringify.js @@ -17,14 +17,21 @@ import { /** * Turn a value into a JSON string that can be parsed with `devalue.parse` * @param {any} value + * @param {Record any>} reducers */ -export function stringify(value) { +export function stringify(value, reducers) { /** @type {any[]} */ const stringified = []; /** @type {Map} */ const indexes = new Map(); + /** @type {Array<{ key: string, fn: (value: any) => any }>} */ + const custom = []; + for (const key in reducers) { + custom.push({ key, fn: reducers[key] }); + } + /** @type {string[]} */ const keys = []; @@ -47,6 +54,14 @@ export function stringify(value) { const index = p++; indexes.set(thing, index); + for (const { key, fn } of custom) { + const value = fn(thing); + if (value) { + stringified[index] = `["${key}",${flatten(value)}]`; + return index; + } + } + let str = ''; if (is_primitive(thing)) { diff --git a/test/test.js b/test/test.js index d440b41..3780f5a 100644 --- a/test/test.js +++ b/test/test.js @@ -3,6 +3,12 @@ import * as assert from 'uvu/assert'; import * as uvu from 'uvu'; import { uneval, unflatten, parse, stringify } from '../index.js'; +class Custom { + constructor(value) { + this.value = value; + } +} + const fixtures = { basics: [ { @@ -374,12 +380,32 @@ const fixtures = { assert.equal(Object.keys(value).length, 0); } } + ], + + custom: [ + { + name: 'Custom type', + value: new Custom({ answer: 42 }), + js: null, + json: '[["Custom",1],{"answer":2},42]', + reducers: { + Custom: (value) => value instanceof Custom && value.value + }, + revivers: { + Custom: (value) => new Custom(value) + }, + validate: (obj) => { + assert.ok(obj instanceof Custom); + assert.equal(obj.value.answer, 42); + } + } ] }; for (const [name, tests] of Object.entries(fixtures)) { const test = uvu.suite(`uneval: ${name}`); for (const t of tests) { + if (t.reducers) continue; test(t.name, () => { const actual = uneval(t.value); const expected = t.js; @@ -393,7 +419,7 @@ for (const [name, tests] of Object.entries(fixtures)) { const test = uvu.suite(`stringify: ${name}`); for (const t of tests) { test(t.name, () => { - const actual = stringify(t.value); + const actual = stringify(t.value, t.reducers); const expected = t.json; assert.equal(actual, expected); }); @@ -405,7 +431,7 @@ for (const [name, tests] of Object.entries(fixtures)) { const test = uvu.suite(`parse: ${name}`); for (const t of tests) { test(t.name, () => { - const actual = parse(t.json); + const actual = parse(t.json, t.revivers); const expected = t.value; if (t.validate) { @@ -421,6 +447,7 @@ for (const [name, tests] of Object.entries(fixtures)) { for (const [name, tests] of Object.entries(fixtures)) { const test = uvu.suite(`unflatten: ${name}`); for (const t of tests) { + if (t.reducers) continue; test(t.name, () => { const actual = unflatten(JSON.parse(t.json)); const expected = t.value; From 162c66895e5f109e5a1b11d9d16abde69531d7d5 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 9 Feb 2023 19:35:51 -0500 Subject: [PATCH 2/7] ensure repeated references are safe --- test/test.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/test/test.js b/test/test.js index 3780f5a..b57f0f5 100644 --- a/test/test.js +++ b/test/test.js @@ -382,24 +382,25 @@ const fixtures = { } ], - custom: [ + custom: ((instance) => [ { name: 'Custom type', - value: new Custom({ answer: 42 }), + value: [instance, instance], js: null, - json: '[["Custom",1],{"answer":2},42]', + json: '[[1,1],["Custom",2],{"answer":3},42]', reducers: { - Custom: (value) => value instanceof Custom && value.value + Custom: (x) => x instanceof Custom && x.value }, revivers: { - Custom: (value) => new Custom(value) + Custom: (x) => new Custom(x) }, - validate: (obj) => { - assert.ok(obj instanceof Custom); - assert.equal(obj.value.answer, 42); + validate: ([obj1, obj2]) => { + assert.is(obj1, obj2); + assert.ok(obj1 instanceof Custom); + assert.equal(obj1.value.answer, 42); } } - ] + ])(new Custom({ answer: 42 })) }; for (const [name, tests] of Object.entries(fixtures)) { From 9c3420f71f50ff212b3e73bd1aec9fd398cf8b52 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 9 Feb 2023 19:38:23 -0500 Subject: [PATCH 3/7] failing test --- test/test.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/test.js b/test/test.js index b57f0f5..ca407bb 100644 --- a/test/test.js +++ b/test/test.js @@ -386,7 +386,7 @@ const fixtures = { { name: 'Custom type', value: [instance, instance], - js: null, + js: '(function(a){return [a,a]}(new Custom({answer:42})))', json: '[[1,1],["Custom",2],{"answer":3},42]', reducers: { Custom: (x) => x instanceof Custom && x.value @@ -406,7 +406,6 @@ const fixtures = { for (const [name, tests] of Object.entries(fixtures)) { const test = uvu.suite(`uneval: ${name}`); for (const t of tests) { - if (t.reducers) continue; test(t.name, () => { const actual = uneval(t.value); const expected = t.js; @@ -448,9 +447,8 @@ for (const [name, tests] of Object.entries(fixtures)) { for (const [name, tests] of Object.entries(fixtures)) { const test = uvu.suite(`unflatten: ${name}`); for (const t of tests) { - if (t.reducers) continue; test(t.name, () => { - const actual = unflatten(JSON.parse(t.json)); + const actual = unflatten(JSON.parse(t.json), t.revivers); const expected = t.value; if (t.validate) { From 7edc7900f707f0766b07c7fb42d69a39a6822346 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 9 Feb 2023 19:44:32 -0500 Subject: [PATCH 4/7] readme driven development --- README.md | 12 ++++++++++++ src/uneval.js | 3 ++- test/test.js | 5 +++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 97af01c..94944e3 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,18 @@ console.log(vector.magnitude()); // 50 If a function passed to `stringify` returns a truthy value, it's treated as a match. +You can also use custom types with `uneval` by specifying a custom replacer: + +```js +devalue.uneval(vector, (value, uneval) => { + if (value instanceof Vector) { + return `new Vector(${value.x},${value.y})`; + } +}); // `new Vector(30,40)` +``` + +Note that any variables referenced in the resulting JavaScript (like `Vector` in the example above) must be in scope when it runs. + ## Error handling If `uneval` or `stringify` encounters a function or a non-POJO, it will throw an error. You can find where in the input data the offending value lives by inspecting `error.path`: diff --git a/src/uneval.js b/src/uneval.js index 305fb6a..47fef39 100644 --- a/src/uneval.js +++ b/src/uneval.js @@ -15,8 +15,9 @@ const reserved = /** * Turn a value into the JavaScript that creates an equivalent value * @param {any} value + * @param {(value: any) => string | void} replacer */ -export function uneval(value) { +export function uneval(value, replacer) { const counts = new Map(); /** @type {string[]} */ diff --git a/test/test.js b/test/test.js index ca407bb..9fb65f7 100644 --- a/test/test.js +++ b/test/test.js @@ -388,6 +388,11 @@ const fixtures = { value: [instance, instance], js: '(function(a){return [a,a]}(new Custom({answer:42})))', json: '[[1,1],["Custom",2],{"answer":3},42]', + replacer: (value, uneval) => { + if (value instanceof Custom) { + return `new Custom(${uneval(value.value)})`; + } + }, reducers: { Custom: (x) => x instanceof Custom && x.value }, From ad4a7bb05d4fd7ae06a88dcc4b4d3c31048ca57e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 9 Feb 2023 20:06:00 -0500 Subject: [PATCH 5/7] make reducers/revivers optional --- src/parse.js | 4 ++-- src/stringify.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/parse.js b/src/parse.js index 7b319eb..54cdeba 100644 --- a/src/parse.js +++ b/src/parse.js @@ -10,7 +10,7 @@ import { /** * Revive a value serialized with `devalue.stringify` * @param {string} serialized - * @param {Record any>} revivers + * @param {Record any>} [revivers] */ export function parse(serialized, revivers) { return unflatten(JSON.parse(serialized), revivers); @@ -19,7 +19,7 @@ export function parse(serialized, revivers) { /** * Revive a value flattened with `devalue.stringify` * @param {number | any[]} parsed - * @param {Record any>} revivers + * @param {Record any>} [revivers] */ export function unflatten(parsed, revivers) { if (typeof parsed === 'number') return hydrate(parsed, true); diff --git a/src/stringify.js b/src/stringify.js index eb8dae4..8ce427e 100644 --- a/src/stringify.js +++ b/src/stringify.js @@ -17,7 +17,7 @@ import { /** * Turn a value into a JSON string that can be parsed with `devalue.parse` * @param {any} value - * @param {Record any>} reducers + * @param {Record any>} [reducers] */ export function stringify(value, reducers) { /** @type {any[]} */ From a62408d0abbadd9b2c3521e64997df8e2004b797 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 9 Feb 2023 20:06:16 -0500 Subject: [PATCH 6/7] implement replacers --- src/uneval.js | 26 +++++++++++++++++++++++++- test/test.js | 2 +- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/uneval.js b/src/uneval.js index 47fef39..cafb72b 100644 --- a/src/uneval.js +++ b/src/uneval.js @@ -15,7 +15,7 @@ const reserved = /** * Turn a value into the JavaScript that creates an equivalent value * @param {any} value - * @param {(value: any) => string | void} replacer + * @param {(value: any, callback: (value: any) => any) => string | void} [replacer] */ export function uneval(value, replacer) { const counts = new Map(); @@ -23,6 +23,8 @@ export function uneval(value, replacer) { /** @type {string[]} */ const keys = []; + const custom = new Set(); + /** @param {any} thing */ function walk(thing) { if (typeof thing === 'function') { @@ -37,6 +39,18 @@ export function uneval(value, replacer) { counts.set(thing, 1); + if (replacer) { + const str = replacer(thing, (child) => { + walk(child); + return ''; + }); + + if (typeof str === 'string') { + custom.add(thing); + return; + } + } + const type = get_type(thing); switch (type) { @@ -118,6 +132,11 @@ export function uneval(value, replacer) { return stringify_primitive(thing); } + if (custom.has(thing)) { + const str = replacer(thing, stringify); + if (typeof str === 'string') return str; + } + const type = get_type(thing); switch (type) { @@ -175,6 +194,11 @@ export function uneval(value, replacer) { names.forEach((name, thing) => { params.push(name); + if (custom.has(thing)) { + values.push(/** @type {string} */ (replacer(thing, stringify))); + return; + } + if (is_primitive(thing)) { values.push(stringify_primitive(thing)); return; diff --git a/test/test.js b/test/test.js index 9fb65f7..7fc7b01 100644 --- a/test/test.js +++ b/test/test.js @@ -412,7 +412,7 @@ for (const [name, tests] of Object.entries(fixtures)) { const test = uvu.suite(`uneval: ${name}`); for (const t of tests) { test(t.name, () => { - const actual = uneval(t.value); + const actual = uneval(t.value, t.replacer); const expected = t.js; assert.equal(actual, expected); }); From 57a3234deec5adbf70f916e460d6d8c223716e78 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 9 Feb 2023 20:09:26 -0500 Subject: [PATCH 7/7] make it less clever and brittle --- src/uneval.js | 16 ++++++---------- test/test.js | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/uneval.js b/src/uneval.js index cafb72b..79f35a3 100644 --- a/src/uneval.js +++ b/src/uneval.js @@ -15,7 +15,7 @@ const reserved = /** * Turn a value into the JavaScript that creates an equivalent value * @param {any} value - * @param {(value: any, callback: (value: any) => any) => string | void} [replacer] + * @param {(value: any) => string | void} [replacer] */ export function uneval(value, replacer) { const counts = new Map(); @@ -23,7 +23,7 @@ export function uneval(value, replacer) { /** @type {string[]} */ const keys = []; - const custom = new Set(); + const custom = new Map(); /** @param {any} thing */ function walk(thing) { @@ -40,13 +40,10 @@ export function uneval(value, replacer) { counts.set(thing, 1); if (replacer) { - const str = replacer(thing, (child) => { - walk(child); - return ''; - }); + const str = replacer(thing); if (typeof str === 'string') { - custom.add(thing); + custom.set(thing, str); return; } } @@ -133,8 +130,7 @@ export function uneval(value, replacer) { } if (custom.has(thing)) { - const str = replacer(thing, stringify); - if (typeof str === 'string') return str; + return custom.get(thing); } const type = get_type(thing); @@ -195,7 +191,7 @@ export function uneval(value, replacer) { params.push(name); if (custom.has(thing)) { - values.push(/** @type {string} */ (replacer(thing, stringify))); + values.push(/** @type {string} */ (custom.get(thing))); return; } diff --git a/test/test.js b/test/test.js index 7fc7b01..1857c6b 100644 --- a/test/test.js +++ b/test/test.js @@ -388,7 +388,7 @@ const fixtures = { value: [instance, instance], js: '(function(a){return [a,a]}(new Custom({answer:42})))', json: '[[1,1],["Custom",2],{"answer":3},42]', - replacer: (value, uneval) => { + replacer: (value) => { if (value instanceof Custom) { return `new Custom(${uneval(value.value)})`; }