diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index d3c238980d..bbd93cf2c1 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -659,21 +659,18 @@ declare module 'shared/utils/papi-util' { */ export function deepEqual(a: unknown, b: unknown): boolean; /** - * Converts a JavaScript value to a JSON string, changing `undefined` properties to `null` - * properties in the JSON string. + * Converts a JavaScript value to a JSON string, changing `undefined` properties in the JavaScript + * object to `null` properties in the JSON string. * - * WARNING: `null` and `undefined` values are treated as the same thing by this function and will be - * dropped when passed to {@link deserialize}. For example, `{ a: 1, b: undefined, c: null }` will - * become `{ a: 1 }` after passing through {@link serialize} then {@link deserialize}. If you are - * passing around user data that needs to retain `null` and/or `undefined` values, you should wrap - * them yourself in a string before using this function. Alternatively, you can write your own - * replacer that will preserve `null` and `undefined` values in a way that a custom reviver will - * understand when deserializing. + * WARNING: `null` values will become `undefined` values after passing through {@link serialize} then + * {@link deserialize}. For example, `{ a: 1, b: undefined, c: null }` will become `{ a: 1, b: + * undefined, c: undefined }`. If you are passing around user data that needs to retain `null` + * values, you should wrap them yourself in a string before using this function. Alternatively, you + * can write your own replacer that will preserve `null` in a way that you can recover later. * * @param value A JavaScript value, usually an object or array, to be converted. - * @param replacer A function that transforms the results. Note that all `null` and `undefined` - * values returned by the replacer will be further transformed into a moniker that deserializes - * into `undefined`. + * @param replacer A function that transforms the results. Note that all `undefined` values returned + * by the replacer will be further transformed into `null` in the JSON string. * @param space Adds indentation, white space, and line break characters to the return-value JSON * text to make it easier to read. See the `space` parameter of `JSON.stringify` for more * details. @@ -684,21 +681,20 @@ declare module 'shared/utils/papi-util' { space?: string | number, ): string; /** - * Converts a JSON string into a value. + * Converts a JSON string into a value, converting all `null` properties from JSON into `undefined` + * in the returned JavaScript value/object. * - * WARNING: `null` and `undefined` values that were serialized by {@link serialize} will both be made - * into `undefined` values by this function. If those values are properties of objects, those - * properties will simply be dropped. For example, `{ a: 1, b: undefined, c: null }` will become `{ - * a: 1 }` after passing through {@link serialize} then {@link deserialize}. If you are passing around - * user data that needs to retain `null` and/or `undefined` values, you should wrap them yourself in - * a string before using this function. Alternatively, you can write your own reviver that will - * preserve `null` and `undefined` values in a way that a custom replacer will encode when - * serializing. + * WARNING: `null` values will become `undefined` values after passing through {@link serialize} then + * {@link deserialize}. For example, `{ a: 1, b: undefined, c: null }` will become `{ a: 1, b: + * undefined, c: undefined }`. If you are passing around user data that needs to retain `null` + * values, you should wrap them yourself in a string before using this function. Alternatively, you + * can write your own replacer that will preserve `null` in a way that you can recover later. * * @param text A valid JSON string. * @param reviver A function that transforms the results. This function is called for each member of * the object. If a member contains nested objects, the nested objects are transformed before the - * parent object is. + * parent object is. Note that `null` values are converted into `undefined` values after the + * reviver has run. */ export function deserialize( value: string, @@ -711,8 +707,7 @@ declare module 'shared/utils/papi-util' { * @returns True if serializable; false otherwise * * Note: the values `undefined` and `null` are serializable (on their own or in an array), but - * `undefined` and `null` properties of objects are dropped when serializing/deserializing. That - * means `undefined` and `null` properties on a value passed in will cause it to fail. + * `null` values get transformed into `undefined` when serializing/deserializing. * * WARNING: This is inefficient right now as it stringifies, parses, stringifies, and === the value. * Please only use this if you need to diff --git a/src/shared/utils/papi-util.test.ts b/src/shared/utils/papi-util.test.ts index e4bbd05af9..e29025ce5d 100644 --- a/src/shared/utils/papi-util.test.ts +++ b/src/shared/utils/papi-util.test.ts @@ -219,20 +219,28 @@ describe('PAPI Util Functions: serialize and deserialize', () => { expect(deepEqual(deserialize('5'), JSON.parse('5'))).toBeTruthy(); expect(serialize('X')).toEqual(JSON.stringify('X')); expect(deepEqual(deserialize('"X"'), JSON.parse('"X"'))).toBeTruthy(); - expect(serialize([3, 5, 7])).toEqual(JSON.stringify([3, 5, 7])); - expect(deepEqual(deserialize('[3,5,7]'), JSON.parse('[3,5,7]'))).toBeTruthy(); - }); - it('exclusively use null in JSON strings and exclusively uses undefined in JS objects', () => { - const testObject = { foo: 'fooValue', bar: undefined, baz: null }; - expect(serialize(testObject)).toEqual( - JSON.stringify({ foo: 'fooValue', bar: null, baz: null }), - ); - expect(deepEqual({ foo: 'fooValue' }, deserialize(serialize(testObject)))).toBeTruthy(); - expect(deepEqual({ foo: 'fooValue' }, deserialize(JSON.stringify(testObject)))).toBeTruthy(); + expect(serialize(true)).toEqual(JSON.stringify(true)); + expect(deepEqual(deserialize('true'), JSON.parse('true'))).toBeTruthy(); + expect(serialize(false)).toEqual(JSON.stringify(false)); + expect(deepEqual(deserialize('false'), JSON.parse('false'))).toBeTruthy(); + expect(serialize([0, 3, 5, 7])).toEqual(JSON.stringify([0, 3, 5, 7])); + expect(deepEqual(deserialize('[0,3,5,7]'), JSON.parse('[0,3,5,7]'))).toBeTruthy(); + expect(serialize({ 1: 'one' })).toEqual(JSON.stringify({ 1: 'one' })); + expect(deepEqual(deserialize('{"1":"one"}'), JSON.parse('{"1":"one"}'))).toBeTruthy(); + }); + it('exclusively use null in JSON strings and exclusively use undefined in JS objects', () => { + const testObject = { foo: 'val', bar: undefined, baz: null }; + expect(serialize(testObject)).toEqual(JSON.stringify({ foo: 'val', bar: null, baz: null })); + expect( + deepEqual({ foo: 'val', bar: undefined, baz: undefined }, deserialize(serialize(testObject))), + ).toBeTruthy(); + expect( + deepEqual({ foo: 'val', baz: undefined }, deserialize(JSON.stringify(testObject))), + ).toBeTruthy(); }); it('handle deeply nested null/undefined values', () => { - const deepNesting = { a: { b: { c: { d: { e: 'something', undef: undefined, nil: null } } } } }; - const roundTrip = { a: { b: { c: { d: { e: 'something' } } } } }; + const deepNesting = { a: { b: { c: { d: 'something', undef: undefined, nil: null } } } }; + const roundTrip = { a: { b: { c: { d: 'something', undef: undefined, nil: undefined } } } }; expect(deepEqual(roundTrip, deserialize(serialize(deepNesting)))).toBeTruthy(); }); it('work with custom replacers/revivers', () => { @@ -243,14 +251,14 @@ describe('PAPI Util Functions: serialize and deserialize', () => { }; const reviver = (_key: string, value: unknown) => { if (value === 10) return 5; - if (value === undefined) return 'resurrected'; + if (value === null) return 'resurrected'; return value; }; expect(serialize(testObject, replacer)).toEqual(serialize({ a: 10 })); expect( deepEqual(testObject, deserialize(serialize(testObject, replacer), reviver)), ).toBeTruthy(); - expect(deserialize(serialize({ lazarus: undefined }), reviver).lazarus).toEqual('resurrected'); + expect(deserialize(serialize({ lazarus: null }), reviver).lazarus).toEqual('resurrected'); }); it('turn null values in an array into undefined when deserializing', () => { // Type asserting after deserializing @@ -326,16 +334,14 @@ describe('PAPI Util Function: isSerializable', () => { expect(isSerializable(objectToSerialize)).toBeTruthy(); }); - it('UNsuccessfully determines object with `undefined` prop is not serializable', () => { - // TODO: make a deserialization algorithm that does this properly. Not a huge deal for now + it('Successfully determines object with `undefined` prop is serializable', () => { const objectToSerialize = { stuff: undefined, things: true }; - expect(isSerializable(objectToSerialize)).toBeFalsy(); + expect(isSerializable(objectToSerialize)).toBeTruthy(); }); - it('UNsuccessfully determines object with `null` prop is not serializable', () => { - // TODO: make a deserialization algorithm that does this properly. Not a huge deal for now + it('Successfully determines object with `null` prop is serializable', () => { const objectToSerialize = { stuff: null, things: true }; - expect(isSerializable(objectToSerialize)).toBeFalsy(); + expect(isSerializable(objectToSerialize)).toBeTruthy(); }); it('UNsuccessfully thinks object with a Map prop is serializable - it should not be', () => { diff --git a/src/shared/utils/papi-util.ts b/src/shared/utils/papi-util.ts index 1d1f8f2b05..0cbd883ad1 100644 --- a/src/shared/utils/papi-util.ts +++ b/src/shared/utils/papi-util.ts @@ -161,21 +161,18 @@ export function deepEqual(a: unknown, b: unknown) { // #region Serialization, deserialization, encoding, and decoding functions /** - * Converts a JavaScript value to a JSON string, changing `undefined` properties to `null` - * properties in the JSON string. + * Converts a JavaScript value to a JSON string, changing `undefined` properties in the JavaScript + * object to `null` properties in the JSON string. * - * WARNING: `null` and `undefined` values are treated as the same thing by this function and will be - * dropped when passed to {@link deserialize}. For example, `{ a: 1, b: undefined, c: null }` will - * become `{ a: 1 }` after passing through {@link serialize} then {@link deserialize}. If you are - * passing around user data that needs to retain `null` and/or `undefined` values, you should wrap - * them yourself in a string before using this function. Alternatively, you can write your own - * replacer that will preserve `null` and `undefined` values in a way that a custom reviver will - * understand when deserializing. + * WARNING: `null` values will become `undefined` values after passing through {@link serialize} then + * {@link deserialize}. For example, `{ a: 1, b: undefined, c: null }` will become `{ a: 1, b: + * undefined, c: undefined }`. If you are passing around user data that needs to retain `null` + * values, you should wrap them yourself in a string before using this function. Alternatively, you + * can write your own replacer that will preserve `null` in a way that you can recover later. * * @param value A JavaScript value, usually an object or array, to be converted. - * @param replacer A function that transforms the results. Note that all `null` and `undefined` - * values returned by the replacer will be further transformed into a moniker that deserializes - * into `undefined`. + * @param replacer A function that transforms the results. Note that all `undefined` values returned + * by the replacer will be further transformed into `null` in the JSON string. * @param space Adds indentation, white space, and line break characters to the return-value JSON * text to make it easier to read. See the `space` parameter of `JSON.stringify` for more * details. @@ -197,21 +194,20 @@ export function serialize( } /** - * Converts a JSON string into a value. + * Converts a JSON string into a value, converting all `null` properties from JSON into `undefined` + * in the returned JavaScript value/object. * - * WARNING: `null` and `undefined` values that were serialized by {@link serialize} will both be made - * into `undefined` values by this function. If those values are properties of objects, those - * properties will simply be dropped. For example, `{ a: 1, b: undefined, c: null }` will become `{ - * a: 1 }` after passing through {@link serialize} then {@link deserialize}. If you are passing around - * user data that needs to retain `null` and/or `undefined` values, you should wrap them yourself in - * a string before using this function. Alternatively, you can write your own reviver that will - * preserve `null` and `undefined` values in a way that a custom replacer will encode when - * serializing. + * WARNING: `null` values will become `undefined` values after passing through {@link serialize} then + * {@link deserialize}. For example, `{ a: 1, b: undefined, c: null }` will become `{ a: 1, b: + * undefined, c: undefined }`. If you are passing around user data that needs to retain `null` + * values, you should wrap them yourself in a string before using this function. Alternatively, you + * can write your own replacer that will preserve `null` in a way that you can recover later. * * @param text A valid JSON string. * @param reviver A function that transforms the results. This function is called for each member of * the object. If a member contains nested objects, the nested objects are transformed before the - * parent object is. + * parent object is. Note that `null` values are converted into `undefined` values after the + * reviver has run. */ export function deserialize( value: string, @@ -219,17 +215,28 @@ export function deserialize( // Need to use `any` instead of `unknown` here to match the signature of JSON.parse // eslint-disable-next-line @typescript-eslint/no-explicit-any ): any { - const undefinedReviver = (replacerKey: string, replacerValue: unknown) => { - let newValue = replacerValue; - // All `null` values become `undefined` on the way from JSON strings into JS objects - // eslint-disable-next-line no-null/no-null - if (newValue === null) newValue = undefined; - if (reviver) newValue = reviver(replacerKey, newValue); - return newValue; - }; - // TODO: Do something like drop our custom reviver and crawl the object tree to replace all null - // properties with undefined properties so that undefined properties don't disappear. - return JSON.parse(value, undefinedReviver); + // Helper function to replace `null` with `undefined` on a per property basis. This can't be done + // with our own reviver because `JSON.parse` removes `undefined` properties from the return value. + function replaceNull(obj: Record): Record { + Object.keys(obj).forEach((key: string | number) => { + // We only want to replace `null`, not other falsy values + // eslint-disable-next-line no-null/no-null + if (obj[key] === null) obj[key] = undefined; + // If the property is an object, recursively call the helper function on it + else if (typeof obj[key] === 'object') + // Since the object came from a string, we know the keys will not be symbols + // eslint-disable-next-line no-type-assertion/no-type-assertion + obj[key] = replaceNull(obj[key] as Record); + }); + return obj; + } + + const parsedObject = JSON.parse(value, reviver); + // Explicitly convert the value 'null' that isn't stored as a property on an object to 'undefined' + // eslint-disable-next-line no-null/no-null + if (parsedObject === null) return undefined; + if (typeof parsedObject === 'object') return replaceNull(parsedObject); + return parsedObject; } /** @@ -239,8 +246,7 @@ export function deserialize( * @returns True if serializable; false otherwise * * Note: the values `undefined` and `null` are serializable (on their own or in an array), but - * `undefined` and `null` properties of objects are dropped when serializing/deserializing. That - * means `undefined` and `null` properties on a value passed in will cause it to fail. + * `null` values get transformed into `undefined` when serializing/deserializing. * * WARNING: This is inefficient right now as it stringifies, parses, stringifies, and === the value. * Please only use this if you need to