diff --git a/.prettierignore b/.prettierignore index 0c903f42751..1bac849192e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,4 @@ src/emoji/__tests__/data-test.js # We're not allowing Prettier to touch some of our vendored code. -src/third/redux-persist +src/third/redux-persist \ No newline at end of file diff --git a/flow-typed/remotedev-serialize_vx.x.x.js b/flow-typed/remotedev-serialize_vx.x.x.js deleted file mode 100644 index 75193614549..00000000000 --- a/flow-typed/remotedev-serialize_vx.x.x.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * From DefinitelyTyped - * (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/55ebcedca/types/remotedev-serialize/index.d.ts) - * via FlowGen v1.10.0, with minimal manual tweaks (run - * - * `git log --stat -p --full-diff -- flow-typed/remotedev-serialize_vx.x.x.js` - * - * for info). - */ -declare module 'remotedev-serialize' { - declare export type Options = { [key: string]: boolean, ... }; - declare export type Refs = { [key: string]: any, ... }; - declare export type DefaultReplacer = (key: string, value: any) => any; - declare export type Replacer = (key: string, value: any, replacer: DefaultReplacer) => any; - declare export type DefaultReviver = (key: string, value: any) => any; - declare export type Reviver = (key: string, value: any, reviver: DefaultReviver) => any; - declare export function immutable( - // This `any` is unavoidable; see - // https://github.com/flow-typed/flow-typed/blob/master/CONTRIBUTING.md#dont-import-types-from-other-libdefs. - immutable: any, - refs?: Refs | null, - customReplacer?: Replacer, - customReviver?: Reviver, - ): { - stringify: (input: any) => string, - parse: (input: string) => any, - serialize: ( - immutable: any, - refs?: Refs, - customReplacer?: Replacer, - customReviver?: Reviver, - ) => { - replacer: Replacer, - reviver: Reviver, - options: Options, - ... - }, - ... - }; -} diff --git a/package.json b/package.json index 096d6d3c60b..e091ac682c8 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,6 @@ "redux-batched-actions": "^0.3.0", "redux-logger": "^3.0.1", "redux-thunk": "^2.1.0", - "remotedev-serialize": "zulip/remotedev-serialize#5f9f759a4", "reselect": "^3.0.1", "rn-fetch-blob": "^0.11.0", "string.fromcodepoint": "^0.2.1", diff --git a/src/boot/__tests__/__snapshots__/replaceRevive-test.js.snap b/src/boot/__tests__/__snapshots__/replaceRevive-test.js.snap new file mode 100644 index 00000000000..1da54cef8db --- /dev/null +++ b/src/boot/__tests__/__snapshots__/replaceRevive-test.js.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Stringify fallbackAvatarURL 1`] = `"{\\"data\\":\\"https://zulip.example.org/avatar/1\\",\\"__serializedType__\\":\\"FallbackAvatarURL\\"}"`; + +exports[`Stringify gravatarURL 1`] = `"{\\"data\\":\\"https://secure.gravatar.com/avatar/3b01d0f626dc6944ed45dbe6c86d3e30?d=identicon\\",\\"__serializedType__\\":\\"GravatarURL\\"}"`; + +exports[`Stringify map 1`] = `"{\\"data\\":{\\"a\\":1,\\"b\\":2,\\"c\\":3,\\"d\\":4},\\"__serializedType__\\":\\"ImmutableMap\\"}"`; + +exports[`Stringify mapWithTypeKey 1`] = `"{\\"data\\":{\\"__serializedType__\\":\\"Object\\",\\"data\\":{\\"a\\":1},\\"__serializedType__value\\":{\\"__serializedType__\\":\\"Object\\",\\"data\\":{\\"b\\":[2]},\\"__serializedType__value\\":{\\"c\\":[3]}}},\\"__serializedType__\\":\\"ImmutableMap\\"}"`; + +exports[`Stringify plainObjectWithTypeKey 1`] = `"{\\"__serializedType__\\":\\"Object\\",\\"data\\":{\\"a\\":1},\\"__serializedType__value\\":{\\"__serializedType__\\":\\"Object\\",\\"data\\":{\\"b\\":[2]},\\"__serializedType__value\\":{\\"c\\":[3]}}}"`; + +exports[`Stringify uploadedAvatarURL 1`] = `"{\\"data\\":\\"https://zulip.example.org/user_avatars/2/e35cdbc4771c5e4b94e705bf6ff7cca7fa1efcae.png?x=x&version=2\\",\\"__serializedType__\\":\\"UploadedAvatarURL\\"}"`; + +exports[`Stringify url 1`] = `"{\\"data\\":\\"https://chat.zulip.org/\\",\\"__serializedType__\\":\\"URL\\"}"`; + +exports[`Stringify zulipVersion 1`] = `"{\\"data\\":\\"3.0.0\\",\\"__serializedType__\\":\\"ZulipVersion\\"}"`; diff --git a/src/boot/__tests__/replaceRevive-test.js b/src/boot/__tests__/replaceRevive-test.js new file mode 100644 index 00000000000..ad3d95630a8 --- /dev/null +++ b/src/boot/__tests__/replaceRevive-test.js @@ -0,0 +1,76 @@ +/* @flow strict-local */ +/* eslint-disable id-match */ +/* eslint-disable no-underscore-dangle */ + +import Immutable from 'immutable'; + +import { FallbackAvatarURL, GravatarURL, UploadedAvatarURL } from '../../utils/avatar'; +import { ZulipVersion } from '../../utils/zulipVersion'; +import { stringify, parse, SERIALIZED_TYPE_FIELD_NAME } from '../replaceRevive'; +import * as eg from '../../__tests__/lib/exampleData'; + +const data = { + map: Immutable.Map({ a: 1, b: 2, c: 3, d: 4 }), + mapWithTypeKey: Immutable.Map({ + a: 1, + [SERIALIZED_TYPE_FIELD_NAME]: { + b: [2], + [SERIALIZED_TYPE_FIELD_NAME]: { c: [3] }, + }, + }), + zulipVersion: new ZulipVersion('3.0.0'), + url: new URL('https://chat.zulip.org'), + gravatarURL: GravatarURL.validateAndConstructInstance({ email: eg.selfUser.email }), + uploadedAvatarURL: UploadedAvatarURL.validateAndConstructInstance({ + realm: eg.realm, + absoluteOrRelativeUrl: + '/user_avatars/2/e35cdbc4771c5e4b94e705bf6ff7cca7fa1efcae.png?x=x&version=2', + }), + fallbackAvatarURL: FallbackAvatarURL.validateAndConstructInstance({ + realm: eg.realm, + userId: 1, + }), + plainObjectWithTypeKey: { + a: 1, + [SERIALIZED_TYPE_FIELD_NAME]: { + b: [2], + [SERIALIZED_TYPE_FIELD_NAME]: { c: [3] }, + }, + }, +}; + +const stringified = {}; +describe('Stringify', () => { + Object.keys(data).forEach(key => { + it(key, () => { + stringified[key] = stringify(data[key]); + expect(stringified[key]).toMatchSnapshot(); + }); + }); + + test('catches an unexpectedly unhandled value with a `toJSON` method', () => { + expect(() => stringify(new Date())).toThrow(); + }); + + test("catches a value that's definitely not serializable as-is", () => { + expect(() => stringify(() => 'foo')).toThrow(); + }); + + test('catches an unexpectedly unhandled value of an interesting type', () => { + class Dog { + noise = 'woof'; + makeNoise() { + console.log(this.noise); // eslint-disable-line no-console + } + } + expect(() => stringify(new Dog())).toThrow(); + }); +}); + +describe('Parse', () => { + Object.keys(data).forEach(key => { + it(key, () => { + expect(parse(stringified[key])).toEqual(data[key]); + }); + }); +}); diff --git a/src/boot/replaceRevive.js b/src/boot/replaceRevive.js new file mode 100644 index 00000000000..c9f43746500 --- /dev/null +++ b/src/boot/replaceRevive.js @@ -0,0 +1,139 @@ +/* @flow strict-local */ +import invariant from 'invariant'; +import Immutable from 'immutable'; + +import { ZulipVersion } from '../utils/zulipVersion'; +import { GravatarURL, UploadedAvatarURL, FallbackAvatarURL } from '../utils/avatar'; + +/** + * PRIVATE: Exported only for tests. + * + * A special identifier for the type of thing to be replaced/revived. + * + * Use this in the replacer and reviver, below, to make it easier to + * be consistent between them and avoid costly typos. + */ +export const SERIALIZED_TYPE_FIELD_NAME: '__serializedType__' = '__serializedType__'; + +/** + * Like SERIALIZED_TYPE_FIELD_NAME, but with a distinguishing mark. + * + * Used in our strategy to ensure successful round-tripping when data + * has a key identical to SERIALIZED_TYPE_FIELD_NAME. + */ +const SERIALIZED_TYPE_FIELD_NAME_ESCAPED: '__serializedType__value' = '__serializedType__value'; + +// Don't make this an arrow function -- we need `this` to be a special +// value; see +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter. +const replacer = function replacer(key, value) { + // The value at the current path before JSON.stringify called its + // `toJSON` method, if present. + // + // When identifying what kind of thing we're working with, be sure + // to examine `origValue` instead of `value`, if calling `toJSON` on + // that kind of thing would remove its identifying features -- which + // is to say, if that kind of thing has a `toJSON` method. + // + // For things that have a `toJSON` method, it may be convenient to + // set `data` to `value`, if we trust that `toJSON` gives the output + // we want to store there. And it would mean we don't discard the + // work `JSON.stringify` did by calling `toJSON`. + const origValue = this[key]; + if (value instanceof ZulipVersion) { + return { data: value.raw(), [SERIALIZED_TYPE_FIELD_NAME]: 'ZulipVersion' }; + } else if (origValue instanceof URL) { + return { data: origValue.toString(), [SERIALIZED_TYPE_FIELD_NAME]: 'URL' }; + } else if (value instanceof GravatarURL) { + return { data: GravatarURL.serialize(value), [SERIALIZED_TYPE_FIELD_NAME]: 'GravatarURL' }; + } else if (value instanceof UploadedAvatarURL) { + return { + data: UploadedAvatarURL.serialize(value), + [SERIALIZED_TYPE_FIELD_NAME]: 'UploadedAvatarURL', + }; + } else if (value instanceof FallbackAvatarURL) { + return { + data: FallbackAvatarURL.serialize(value), + [SERIALIZED_TYPE_FIELD_NAME]: 'FallbackAvatarURL', + }; + } else if (Immutable.Map.isMap(origValue)) { + return { data: value, [SERIALIZED_TYPE_FIELD_NAME]: 'ImmutableMap' }; + } + + if (typeof origValue === 'object' && origValue !== null) { + // Don't forget to handle a value's `toJSON` method, if present, as + // described above. + invariant(typeof origValue.toJSON !== 'function', 'unexpected toJSON'); + + // If storing an interesting data type, don't forget to handle it + // here, and in `reviver`. + const origValuePrototype = Object.getPrototypeOf(origValue); + invariant( + // Flow bug: https://github.com/facebook/flow/issues/6110 + origValuePrototype === (Object.prototype: $FlowFixMe) + || origValuePrototype === (Array.prototype: $FlowFixMe), + 'unexpected class', + ); + } + + if (typeof value === 'object' && value !== null && SERIALIZED_TYPE_FIELD_NAME in value) { + const copy = { ...value }; + delete copy[SERIALIZED_TYPE_FIELD_NAME]; + return { + [SERIALIZED_TYPE_FIELD_NAME]: 'Object', + data: copy, + [SERIALIZED_TYPE_FIELD_NAME_ESCAPED]: value[SERIALIZED_TYPE_FIELD_NAME], + }; + } + + return value; +}; + +const reviver = function reviver(key, value) { + if (value !== null && typeof value === 'object' && SERIALIZED_TYPE_FIELD_NAME in value) { + const data = value.data; + switch (value[SERIALIZED_TYPE_FIELD_NAME]) { + case 'ZulipVersion': + return new ZulipVersion(data); + case 'URL': + return new URL(data); + case 'GravatarURL': + return GravatarURL.deserialize(data); + case 'UploadedAvatarURL': + return UploadedAvatarURL.deserialize(data); + case 'FallbackAvatarURL': + return FallbackAvatarURL.deserialize(data); + case 'ImmutableMap': + return Immutable.Map(data); + case 'Object': + return { + ...data, + [SERIALIZED_TYPE_FIELD_NAME]: value[SERIALIZED_TYPE_FIELD_NAME_ESCAPED], + }; + default: + return data; + } + } + return value; +}; + +export const stringify = function stringify(data: mixed): string { + const result = JSON.stringify(data, replacer); + if (result === undefined) { + // Flow says that the output for JSON.stringify could be + // undefined. From MDN: + // + // `JSON.stringify()` can return `undefined` when passing in + // "pure" values like `JSON.stringify(function(){})` or + // `JSON.stringify(undefined)`. + // + // We don't expect any of those inputs, but we'd want to know if + // we get one, since it means something has gone quite wrong. + throw new Error('undefined result for stringify'); + } + return result; +}; + +export const parse = function parse(data: string): mixed { + return JSON.parse(data, reviver); +}; diff --git a/src/boot/store.js b/src/boot/store.js index c944a886ebc..09873c239db 100644 --- a/src/boot/store.js +++ b/src/boot/store.js @@ -5,12 +5,11 @@ import thunkMiddleware from 'redux-thunk'; import { createLogger } from 'redux-logger'; import createActionBuffer from 'redux-action-buffer'; import Immutable from 'immutable'; -import * as Serialize from 'remotedev-serialize'; import { persistStore, autoRehydrate } from '../third/redux-persist'; import type { Config } from '../third/redux-persist'; import { ZulipVersion } from '../utils/zulipVersion'; -import { GravatarURL, UploadedAvatarURL, FallbackAvatarURL } from '../utils/avatar'; +import { stringify, parse } from './replaceRevive'; import type { Action, GlobalState } from '../types'; import config from '../config'; import { REHYDRATE } from '../actionConstants'; @@ -327,58 +326,6 @@ provideLoggingContext(() => ({ serverVersion: tryGetActiveAccount(store.getState())?.zulipVersion ?? null, })); -/** - * A special identifier used by `remotedev-serialize`. - * - * Use this in the custom replacer and reviver, below, to make it - * easier to be consistent between them and avoid costly typos. - */ -const SERIALIZED_TYPE_FIELD_NAME: '__serializedType__' = '__serializedType__'; - -const customReplacer = (key, value, defaultReplacer) => { - if (value instanceof ZulipVersion) { - return { data: value.raw(), [SERIALIZED_TYPE_FIELD_NAME]: 'ZulipVersion' }; - } else if (value instanceof URL) { - return { data: value.toString(), [SERIALIZED_TYPE_FIELD_NAME]: 'URL' }; - } else if (value instanceof GravatarURL) { - return { data: GravatarURL.serialize(value), [SERIALIZED_TYPE_FIELD_NAME]: 'GravatarURL' }; - } else if (value instanceof UploadedAvatarURL) { - return { - data: UploadedAvatarURL.serialize(value), - [SERIALIZED_TYPE_FIELD_NAME]: 'UploadedAvatarURL', - }; - } else if (value instanceof FallbackAvatarURL) { - return { - data: FallbackAvatarURL.serialize(value), - [SERIALIZED_TYPE_FIELD_NAME]: 'FallbackAvatarURL', - }; - } - return defaultReplacer(key, value); -}; - -const customReviver = (key, value, defaultReviver) => { - if (value !== null && typeof value === 'object' && SERIALIZED_TYPE_FIELD_NAME in value) { - const data = value.data; - switch (value[SERIALIZED_TYPE_FIELD_NAME]) { - case 'ZulipVersion': - return new ZulipVersion(data); - case 'URL': - return new URL(data); - case 'GravatarURL': - return GravatarURL.deserialize(data); - case 'UploadedAvatarURL': - return UploadedAvatarURL.deserialize(data); - case 'FallbackAvatarURL': - return FallbackAvatarURL.deserialize(data); - default: - // Fall back to defaultReviver, below - } - } - return defaultReviver(key, value); -}; - -const { stringify, parse } = Serialize.immutable(Immutable, null, customReplacer, customReviver); - /** * The config options to pass to redux-persist. * diff --git a/src/utils/zulipVersion.js b/src/utils/zulipVersion.js index 58607f46ab1..78d4c6b49a4 100644 --- a/src/utils/zulipVersion.js +++ b/src/utils/zulipVersion.js @@ -39,17 +39,21 @@ export class ZulipVersion { /** * The raw version string that was passed to the constructor. */ - raw = () => this._raw; + raw() { + return this._raw; + } /** * Data to be sent to Sentry to help with event aggregation. */ - elements = () => this._elements; + elements() { + return this._elements; + } /** * True if this version is later than or equal to a given threshold. */ - isAtLeast = (otherZulipVersion: string | ZulipVersion) => { + isAtLeast(otherZulipVersion: string | ZulipVersion) { const otherZulipVersionInstance = otherZulipVersion instanceof ZulipVersion ? otherZulipVersion @@ -66,12 +70,12 @@ export class ZulipVersion { // It's a tie so far, and one of the arrays has ended. The array with // further elements wins. return this._comparisonArray.length >= otherComparisonArray.length; - }; + } /** * Parse the raw string into a VersionElements. */ - static _getElements = (raw: string): VersionElements => { + static _getElements(raw: string): VersionElements { const result: VersionElements = { major: undefined, minor: undefined, @@ -110,12 +114,12 @@ export class ZulipVersion { } return result; - }; + } /** * Compute a number[] to be used in .isAtLeast comparisons. */ - static _getComparisonArray = (elements: VersionElements): number[] => { + static _getComparisonArray(elements: VersionElements): number[] { const { major, minor, patch, flag, numCommits } = elements; const result: number[] = []; @@ -149,5 +153,5 @@ export class ZulipVersion { } return result; - }; + } } diff --git a/yarn.lock b/yarn.lock index 6355be3d68d..dc7945f69e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7669,11 +7669,6 @@ js-yaml@^3.13.0, js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -jsan@^3.1.13: - version "3.1.13" - resolved "https://registry.yarnpkg.com/jsan/-/jsan-3.1.13.tgz#4de8c7bf8d1cfcd020c313d438f930cec4b91d86" - integrity sha512-9kGpCsGHifmw6oJet+y8HaCl14y7qgAsxVdV3pCHDySNR3BfDC30zgkssd7x5LRVAT22dnpbe9JdzzmXZnq9/g== - jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -10524,12 +10519,6 @@ regjsparser@^0.6.4: dependencies: jsesc "~0.5.0" -remotedev-serialize@zulip/remotedev-serialize#5f9f759a4: - version "0.1.8" - resolved "https://codeload.github.com/zulip/remotedev-serialize/tar.gz/5f9f759a4f82821aedfd23aea686e1f784750189" - dependencies: - jsan "^3.1.13" - remove-trailing-separator@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"