packages/shared/util/Recoil_stableStringify.js (92 lines of code) (raw):
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails oncall+recoil
* @flow strict
* @format
*/
'use strict';
const err = require('./Recoil_err');
const isPromise = require('./Recoil_isPromise');
const TIME_WARNING_THRESHOLD_MS = 15;
type Options = $ReadOnly<{allowFunctions?: boolean}>;
function stringify(x: mixed, opt: Options, key?: string): string {
// A optimization to avoid the more expensive JSON.stringify() for simple strings
// This may lose protection for u2028 and u2029, though.
if (typeof x === 'string' && !x.includes('"') && !x.includes('\\')) {
return `"${x}"`;
}
// Handle primitive types
switch (typeof x) {
case 'undefined':
return ''; // JSON.stringify(undefined) returns undefined, but we always want to return a string
case 'boolean':
return x ? 'true' : 'false';
case 'number':
case 'symbol':
// case 'bigint': // BigInt is not supported in www
return String(x);
case 'string':
// Add surrounding quotes and escape internal quotes
return JSON.stringify(x);
case 'function':
if (opt?.allowFunctions !== true) {
throw err('Attempt to serialize function in a Recoil cache key');
}
return `__FUNCTION(${x.name})__`;
}
if (x === null) {
return 'null';
}
// Fallback case for unknown types
if (typeof x !== 'object') {
return JSON.stringify(x) ?? '';
}
// Deal with all promises as equivalent for now.
if (isPromise(x)) {
return '__PROMISE__';
}
// Arrays handle recursive stringification
if (Array.isArray(x)) {
return `[${x.map((v, i) => stringify(v, opt, i.toString()))}]`;
}
// If an object defines a toJSON() method, then use that to override the
// serialization. This matches the behavior of JSON.stringify().
// Pass the key for compatibility.
// Immutable.js collections define this method to allow us to serialize them.
if (typeof x.toJSON === 'function') {
// flowlint-next-line unclear-type: off
return stringify((x: any).toJSON(key), opt, key);
}
// For built-in Maps, sort the keys in a stable order instead of the
// default insertion order. Support non-string keys.
if (x instanceof Map) {
const obj: {[string]: $FlowFixMe} = {};
for (const [k, v] of x) {
// Stringify will escape any nested quotes
obj[typeof k === 'string' ? k : stringify(k, opt)] = v;
}
return stringify(obj, opt, key);
}
// For built-in Sets, sort the keys in a stable order instead of the
// default insertion order.
if (x instanceof Set) {
return stringify(
Array.from(x).sort((a, b) =>
stringify(a, opt).localeCompare(stringify(b, opt)),
),
opt,
key,
);
}
// Anything else that is iterable serialize as an Array.
if (
Symbol !== undefined &&
x[Symbol.iterator] != null &&
typeof x[Symbol.iterator] === 'function'
) {
// flowlint-next-line unclear-type: off
return stringify(Array.from((x: any)), opt, key);
}
// For all other Objects, sort the keys in a stable order.
return `{${Object.keys(x)
.filter(k => x[k] !== undefined)
.sort()
// stringify the key to add quotes and escape any nested slashes or quotes.
.map(k => `${stringify(k, opt)}:${stringify(x[k], opt, k)}`)
.join(',')}}`;
}
// Utility similar to JSON.stringify() except:
// * Serialize built-in Sets as an Array
// * Serialize built-in Maps as an Object. Supports non-string keys.
// * Serialize other iterables as arrays
// * Sort the keys of Objects and Maps to have a stable order based on string conversion.
// This overrides their default insertion order.
// * Still uses toJSON() of any object to override serialization
// * Support Symbols (though don't guarantee uniqueness)
// * We could support BigInt, but Flow doesn't seem to like it.
// See Recoil_stableStringify-test.js for examples
function stableStringify(
x: mixed,
opt: Options = {allowFunctions: false},
): string {
if (__DEV__) {
if (typeof window !== 'undefined') {
const startTime = window.performance ? window.performance.now() : 0;
const str = stringify(x, opt);
const endTime = window.performance ? window.performance.now() : 0;
if (endTime - startTime > TIME_WARNING_THRESHOLD_MS) {
/* eslint-disable fb-www/no-console */
console.groupCollapsed(
`Recoil: Spent ${endTime - startTime}ms computing a cache key`,
);
console.warn(x, str);
console.groupEnd();
/* eslint-enable fb-www/no-console */
}
return str;
}
}
return stringify(x, opt);
}
module.exports = stableStringify;