diff --git a/.erb/configs/webpack.config.renderer.dev.ts b/.erb/configs/webpack.config.renderer.dev.ts index c93509ecf3..2895ed8a75 100644 --- a/.erb/configs/webpack.config.renderer.dev.ts +++ b/.erb/configs/webpack.config.renderer.dev.ts @@ -233,6 +233,8 @@ const configuration: webpack.Configuration = { childProcessInfo.process .on('close', (code: number) => { console.log(`${childProcessInfo.name} is closing!`); + // Null to interact with external API + // eslint-disable-next-line no-null/no-null childProcessInfo.process = null; // Close all other child processes and exit the main process childProcessInfos.forEach((otherProcessInfo, j) => { diff --git a/.eslintrc.js b/.eslintrc.js index ff5e381c5e..0a6ced3c95 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -69,6 +69,7 @@ module.exports = { ], // Should use our logger anytime you want logs that persist. Otherwise use console only in testing 'no-console': 'warn', + 'no-null/no-null': 2, 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], 'no-type-assertion/no-type-assertion': 'error', 'prettier/prettier': ['warn', { tabWidth: 2, trailingComma: 'all' }], @@ -88,7 +89,7 @@ module.exports = { tsconfigRootDir: __dirname, createDefaultProgram: true, }, - plugins: ['@typescript-eslint', 'no-type-assertion'], + plugins: ['@typescript-eslint', 'no-type-assertion', 'no-null'], settings: { 'import/resolver': { // See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below diff --git a/extensions/src/c-sharp-provider-test/manifest.json b/extensions/src/c-sharp-provider-test/manifest.json index 86f31c1470..a1e81eebf9 100644 --- a/extensions/src/c-sharp-provider-test/manifest.json +++ b/extensions/src/c-sharp-provider-test/manifest.json @@ -4,6 +4,6 @@ "description": "C# Data Provider Test Types for Paranext - provided by C# data provider", "author": "Paranext", "license": "MIT", - "main": null, + "main": "", "activationEvents": [] } diff --git a/extensions/src/hello-someone/hello-someone.web-view.html b/extensions/src/hello-someone/hello-someone.web-view.html index ba8f605a80..94a7a712eb 100644 --- a/extensions/src/hello-someone/hello-someone.web-view.html +++ b/extensions/src/hello-someone/hello-someone.web-view.html @@ -114,7 +114,7 @@ // Update the 'people' display on load and on updates peopleDataProvider.subscribePeople(undefined, (people) => { const peopleData = document.getElementById('people-data'); - const peopleString = JSON.stringify(people, null, 2); + const peopleString = papi.utils.serialize(people, undefined, 2); peopleData.textContent = papi.utils.htmlEncode(peopleString.replace(/"/g, '`')); print(peopleString); }); diff --git a/extensions/src/project-notes-data-provider/manifest.json b/extensions/src/project-notes-data-provider/manifest.json index 7d78a540b6..cb7592f3c8 100644 --- a/extensions/src/project-notes-data-provider/manifest.json +++ b/extensions/src/project-notes-data-provider/manifest.json @@ -4,6 +4,6 @@ "description": "Project Notes Data Provider for Paranext - provided by C# data provider", "author": "Paranext", "license": "MIT", - "main": null, + "main": "", "activationEvents": [] } diff --git a/extensions/src/resource-viewer/resource-viewer.d.ts b/extensions/src/resource-viewer/resource-viewer.d.ts index 6b4ade44a8..21d4a0a26a 100644 --- a/extensions/src/resource-viewer/resource-viewer.d.ts +++ b/extensions/src/resource-viewer/resource-viewer.d.ts @@ -7,8 +7,9 @@ declare module 'papi-shared-types' { * * @param projectId Optional project ID of the resource to open. Prompts the user to select a * resource project if not provided - * @returns WebView id for new Resource Viewer WebView or `null` if the user canceled the dialog + * @returns WebView id for new Resource Viewer WebView or `undefined` if the user canceled the + * dialog */ - 'resourceViewer.open': (projectId: string | undefined) => Promise; + 'resourceViewer.open': (projectId: string | undefined) => Promise; } } diff --git a/extensions/src/resource-viewer/resource-viewer.ts b/extensions/src/resource-viewer/resource-viewer.ts index 84fee4d312..d56717e558 100644 --- a/extensions/src/resource-viewer/resource-viewer.ts +++ b/extensions/src/resource-viewer/resource-viewer.ts @@ -22,16 +22,14 @@ interface ResourceViewerOptions extends GetWebViewOptions { * Function to prompt for a project and open it in the resource viewer. Registered as a command * handler. */ -async function openResourceViewer( - projectId: string | undefined, -): Promise { +async function openResourceViewer(projectId: string | undefined): Promise { let projectIdForWebView = projectId; if (!projectIdForWebView) { const options: DialogOptions = { title: 'Select Resource', prompt: 'Choose the resource project to view:', }; - projectIdForWebView = (await papi.dialogs.selectProject(options)) ?? undefined; + projectIdForWebView = await papi.dialogs.selectProject(options); } if (projectIdForWebView) { const options: ResourceViewerOptions = { projectId: projectIdForWebView }; @@ -39,7 +37,7 @@ async function openResourceViewer( // This matches the current behavior in P9, though it might not be what we want long-term. return papi.webViews.getWebView(resourceWebViewType, undefined, options); } - return null; + return undefined; } /** Simple web view provider that provides Resource web views when papi requests them */ @@ -58,7 +56,7 @@ const resourceWebViewProvider: IWebViewProvider = { getWebViewOptions.projectId || // eslint-disable-next-line no-type-assertion/no-type-assertion (savedWebView.state?.projectId as string) || - null; + undefined; return { title: projectId ? `Resource Viewer : ${ diff --git a/extensions/src/usfm-data-provider/manifest.json b/extensions/src/usfm-data-provider/manifest.json index 9c97adb0e4..7434609467 100644 --- a/extensions/src/usfm-data-provider/manifest.json +++ b/extensions/src/usfm-data-provider/manifest.json @@ -4,6 +4,6 @@ "description": "USFM Data Provider for Paranext - provided by C# data provider", "author": "Paranext", "license": "MIT", - "main": null, + "main": "", "activationEvents": [] } diff --git a/extensions/webpack/webpack.util.ts b/extensions/webpack/webpack.util.ts index bebe9f07e3..51326c32e6 100644 --- a/extensions/webpack/webpack.util.ts +++ b/extensions/webpack/webpack.util.ts @@ -207,9 +207,10 @@ type ExtensionManifest = { /** * The JavaScript file to run in the extension host. * - * Must be specified. Can be `null` if the extension does not have any JavaScript to run. + * Must be specified. Can be an empty string ('') if the extension does not have any JavaScript to + * run. */ - main: string | null; + main: string; activationEvents: string[]; }; @@ -224,8 +225,8 @@ export type ExtensionInfo = { /** The extension's version */ version: string; /** - * Whether to skip this extension when building. If the manifest main is null, there is no - * JavaScript to build + * Whether to skip this extension when building. If the manifest main is an empty string, there is + * no JavaScript to build */ skipBuildingJavaScript?: boolean; }; @@ -259,7 +260,7 @@ export async function getExtensions(): Promise { }); // Get main file path from the manifest and return extension info - return extensionManifest.main !== null + return extensionManifest.main !== '' ? { dirName: extensionFolderName, entryFileName: path.parse(extensionManifest.main).name, diff --git a/lib/papi-components/src/snackbar.component.tsx b/lib/papi-components/src/snackbar.component.tsx index 016119b308..be51b31cbf 100644 --- a/lib/papi-components/src/snackbar.component.tsx +++ b/lib/papi-components/src/snackbar.component.tsx @@ -30,9 +30,9 @@ export type SnackbarProps = PropsWithChildren<{ /** * The number of milliseconds to wait before automatically calling onClose() * - * @default null + * @default undefined */ - autoHideDuration?: number | null; + autoHideDuration?: number; /** Additional css classes to help with unique styling of the snackbar, external */ className?: string; @@ -61,7 +61,7 @@ export type SnackbarProps = PropsWithChildren<{ * https://mui.com/material-ui/getting-started/overview/ */ function Snackbar({ - autoHideDuration = null, + autoHideDuration = undefined, id, isOpen = false, className, @@ -78,7 +78,7 @@ function Snackbar({ return ( - ) : null} - {children ?
{children}
: null} + ) : undefined} + {children ?
{children}
: undefined} {menu ? ( - ) : null} + ) : undefined} diff --git a/lib/papi-dts/edit-papi-d-ts.ts b/lib/papi-dts/edit-papi-d-ts.ts index 27048eaaa4..4301ed45d9 100644 --- a/lib/papi-dts/edit-papi-d-ts.ts +++ b/lib/papi-dts/edit-papi-d-ts.ts @@ -48,6 +48,8 @@ const jsdocDestinations = new Set(); const jsdocRegex = /\/\*\*(?: *\n[\s]*?\*)*?[\s]*?JSDOC (SOURCE|DESTINATION) (\w+)[\s\S]*?\*\//g; let hitFatalError = false; let match = jsdocRegex.exec(papiDTS); +// When no regex match is found, null is returned +// eslint-disable-next-line no-null/no-null while (match !== null) { const [block, sourceOrDestination, name] = match; if (sourceOrDestination === 'SOURCE') { diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index a8472dde17..78708dc517 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -526,13 +526,13 @@ declare module 'shared/utils/util' { * * @param fn The function to run * @param maxWaitTimeInMS The maximum amount of time to wait for the function to resolve - * @returns Promise that resolves to the resolved value of the function or null if it ran longer - * than the specified wait time + * @returns Promise that resolves to the resolved value of the function or undefined if it ran + * longer than the specified wait time */ export function waitForDuration( fn: () => Promise, maxWaitTimeInMS: number, - ): Promise | null>; + ): Promise | undefined>; /** * Get all functions on an object and its prototype chain (so we don't miss any class methods or any * object methods). Note that the functions on the final item in the prototype chain (i.e., Object) @@ -657,13 +657,60 @@ declare module 'shared/utils/papi-util' { */ export function deepEqual(a: unknown, b: unknown): boolean; /** - * Check to see if the value is `JSON.stringify` serializable without losing information + * Converts a JavaScript value to a JSON string, changing `undefined` properties 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. + * + * @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 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. + */ + export function serialize( + value: unknown, + replacer?: (this: unknown, key: string, value: unknown) => unknown, + space?: string | number, + ): string; + /** + * Converts a JSON string into a value. + * + * 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. + * + * @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. + */ + export function deserialize( + value: string, + reviver?: (this: unknown, key: string, value: unknown) => unknown, + ): any; + /** + * Check to see if the value is serializable without losing information * * @param value Value to test * @returns True if serializable; false otherwise * - * Note: the value `undefined` is not serializable as `JSON.parse` throws on it. `null` is - * serializable. However, `undefined` or `null` on properties of objects is serializable. + * 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. * * WARNING: This is inefficient right now as it stringifies, parses, stringifies, and === the value. * Please only use this if you need to @@ -981,7 +1028,7 @@ declare module 'shared/data/network-connector.model' { * unregister all requests on that client so the reconnecting client can register its request * handlers again. */ - reconnectingClientGuid?: string | null; + reconnectingClientGuid?: string; }; /** Request to do something and to respond */ export type WebSocketRequest = { @@ -2379,7 +2426,7 @@ declare module 'papi-shared-types' { type CommandNames = keyof CommandHandlers; interface SettingTypes { 'platform.verseRef': ScriptureReference; - placeholder: null; + placeholder: undefined; } type SettingNames = keyof SettingTypes; /** This is just a simple example so we have more than one. It's not intended to be real. */ @@ -3160,27 +3207,26 @@ declare module 'shared/services/project-data-provider.service' { declare module 'shared/services/settings.service' { import { Unsubscriber } from 'shared/utils/papi-util'; import { SettingTypes } from 'papi-shared-types'; - type Nullable = T | null; /** * Retrieves the value of the specified setting * * @param key The string id of the setting for which the value is being retrieved - * @returns The value of the specified setting, parsed to an object. Returns `null` if setting is - * not present or no value is available + * @returns The value of the specified setting, parsed to an object. Returns `undefined` if setting + * is not present or no value is available */ const getSetting: ( key: SettingName, - ) => Nullable; + ) => SettingTypes[SettingName] | undefined; /** * Sets the value of the specified setting * * @param key The string id of the setting for which the value is being retrieved - * @param newSetting The value that is to be stored. Setting the new value to `null` is the + * @param newSetting The value that is to be stored. Setting the new value to `undefined` is the * equivalent of deleting the setting */ const setSetting: ( key: SettingName, - newSetting: Nullable, + newSetting: SettingTypes[SettingName] | undefined, ) => void; /** * Subscribes to updates of the specified setting. Whenever the value of the setting changes, the @@ -3192,7 +3238,7 @@ declare module 'shared/services/settings.service' { */ const subscribeToSetting: ( key: SettingName, - callback: (newSetting: Nullable) => void, + callback: (newSetting: SettingTypes[SettingName] | undefined) => void, ) => Unsubscriber; export interface SettingsService { get: typeof getSetting; @@ -3272,7 +3318,7 @@ declare module 'renderer/components/dialogs/dialog-base.data' { * @param data Data with which to resolve the request */ submitDialog(data: TData): void; - /** Cancels the dialog request (resolves the response with `null`) and closes the dialog */ + /** Cancels the dialog request (resolves the response with `undefined`) and closes the dialog */ cancelDialog(): void; /** * Rejects the dialog request with the specified message and closes the dialog @@ -3293,7 +3339,7 @@ declare module 'renderer/components/dialogs/dialog-base.data' { resolveDialogRequest: resolve, rejectDialogRequest: reject, }: { - resolveDialogRequest: (id: string, data: unknown | null) => void; + resolveDialogRequest: (id: string, data: unknown | undefined) => void; rejectDialogRequest: (id: string, message: string) => void; }): void; /** @@ -3397,22 +3443,19 @@ declare module 'shared/services/dialog.service-model' { * @type `TReturn` - The type of data the dialog responds with * @param dialogType The type of dialog to show the user * @param options Various options for configuring the dialog that shows - * @returns Returns the user's response or `null` if the user cancels - * - * Note: canceling responds with `null` instead of `undefined` so that the dialog definition can - * use `undefined` as a meaningful value if desired. + * @returns Returns the user's response or `undefined` if the user cancels */ showDialog( dialogType: DialogTabType, options?: DialogTypes[DialogTabType]['options'], - ): Promise; + ): Promise; /** * Shows a select project dialog to the user and prompts the user to select a dialog * * @param options Various options for configuring the dialog that shows - * @returns Returns the user's selected project id or `null` if the user cancels + * @returns Returns the user's selected project id or `undefined` if the user cancels */ - selectProject(options?: DialogOptions): Promise; + selectProject(options?: DialogOptions): Promise; } /** Prefix on requests that indicates that the request is related to dialog operations */ export const CATEGORY_DIALOG = 'dialog'; @@ -3426,10 +3469,9 @@ declare module 'renderer/hooks/papi-hooks/use-promise.hook' { /** * Awaits a promise and returns a loading value while the promise is unresolved * - * @param promiseFactoryCallback A function that returns the promise to await. If the promise - * resolves to null, the value will not change. If this callback is undefined, the current value - * will be returned (defaultValue unless it was previously changed and preserveValue is true), and - * there will be no loading. + * @param promiseFactoryCallback A function that returns the promise to await. If this callback is + * undefined, the current value will be returned (defaultValue unless it was previously changed + * and preserveValue is true), and there will be no loading. * * WARNING: MUST BE STABLE - const or wrapped in useCallback. The reference must not be updated * every render @@ -3447,7 +3489,7 @@ declare module 'renderer/hooks/papi-hooks/use-promise.hook' { * - `isLoading`: whether the promise is waiting to be resolved */ const usePromise: ( - promiseFactoryCallback: (() => Promise) | undefined, + promiseFactoryCallback: (() => Promise) | undefined, defaultValue: T, preserveValue?: boolean, ) => [value: T, isLoading: boolean]; @@ -3718,7 +3760,7 @@ declare module 'renderer/hooks/papi-hooks/use-setting.hook' { * Gets and sets a setting on the papi. Also notifies subscribers when the setting changes and gets * updated when the setting is changed by others. * - * Setting the value to `null` is the equivalent of deleting the setting. + * Setting the value to `undefined` is the equivalent of deleting the setting. * * @param key The string id that is used to store the setting in local storage * @@ -3884,8 +3926,8 @@ declare module 'renderer/hooks/papi-hooks/use-dialog-callback.hook' { * * @type `DialogTabType` The dialog type you are using. Should be inferred by parameters * @type `TResponse` The type that the response can be. If you do not specify a `defaultResponse`, - * this can be the dialog response type or `null`. If you specify a `defaultResponse`, this will - * be just the dialog response type. Should be inferred by parameters. + * this can be the dialog response type or `undefined`. If you specify a `defaultResponse`, this + * will be just the dialog response type. Should be inferred by parameters. * * - This mostly works. Unfortunately, if you specify a literal as `defaultResponse`, `TResponse` then * becomes that literal instead of being the dialog response type. You can type assert it to @@ -3900,7 +3942,7 @@ declare module 'renderer/hooks/papi-hooks/use-dialog-callback.hook' { * WARNING: MUST BE STABLE - const or wrapped in useState, useMemo, etc. The reference must not be * updated every render * @param defaultResponse The starting value for the response. Once a response is received, this is - * no longer used. Defaults to `null` + * no longer used. Defaults to `undefined` * @returns `[response, showDialogCallback, errorMessage, isShowingDialog]` * * - `response` - the response from the dialog or `defaultResponse` if a response has not been @@ -3915,9 +3957,9 @@ declare module 'renderer/hooks/papi-hooks/use-dialog-callback.hook' { */ function useDialogCallback< DialogTabType extends DialogTabTypes, - TResponse extends DialogTypes[DialogTabType]['responseType'] | null = + TResponse extends DialogTypes[DialogTabType]['responseType'] | undefined = | DialogTypes[DialogTabType]['responseType'] - | null, + | undefined, >( dialogType: DialogTabType, options?: DialogTypes[DialogTabType]['options'], @@ -4780,9 +4822,9 @@ declare module 'extension-host/extension-types/extension-manifest.model' { * Path to the JavaScript file to run in the extension host. Relative to the extension's root * folder. * - * Must be specified. Can be `null` if the extension does not have any JavaScript to run. + * Must be specified. Can be an empty string if the extension does not have any JavaScript to run. */ - main: string | null; + main: string; /** * Path to the TypeScript type declaration file that describes this extension and its interactions * on the PAPI. Relative to the extension's root folder. diff --git a/package-lock.json b/package-lock.json index 10ae577634..73e17b2935 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,6 +86,7 @@ "eslint-plugin-import": "^2.28.1", "eslint-plugin-jest": "^27.2.3", "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-no-null": "^1.0.2", "eslint-plugin-no-type-assertion": "^1.3.0", "eslint-plugin-promise": "^6.1.1", "eslint-plugin-react": "^7.33.2", @@ -13961,6 +13962,18 @@ "semver": "bin/semver.js" } }, + "node_modules/eslint-plugin-no-null": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-no-null/-/eslint-plugin-no-null-1.0.2.tgz", + "integrity": "sha512-uRDiz88zCO/2rzGfgG15DBjNsgwWtWiSo4Ezy7zzajUgpnFIqd1TjepKeRmJZHEfBGu58o2a8S0D7vglvvhkVA==", + "dev": true, + "engines": { + "node": ">=5.0.0" + }, + "peerDependencies": { + "eslint": ">=3.0.0" + } + }, "node_modules/eslint-plugin-no-type-assertion": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/eslint-plugin-no-type-assertion/-/eslint-plugin-no-type-assertion-1.3.0.tgz", @@ -36513,6 +36526,13 @@ } } }, + "eslint-plugin-no-null": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-no-null/-/eslint-plugin-no-null-1.0.2.tgz", + "integrity": "sha512-uRDiz88zCO/2rzGfgG15DBjNsgwWtWiSo4Ezy7zzajUgpnFIqd1TjepKeRmJZHEfBGu58o2a8S0D7vglvvhkVA==", + "dev": true, + "requires": {} + }, "eslint-plugin-no-type-assertion": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/eslint-plugin-no-type-assertion/-/eslint-plugin-no-type-assertion-1.3.0.tgz", diff --git a/package.json b/package.json index c19b43bbc0..6796e86d84 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "eslint-plugin-import": "^2.28.1", "eslint-plugin-jest": "^27.2.3", "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-no-null": "^1.0.2", "eslint-plugin-no-type-assertion": "^1.3.0", "eslint-plugin-promise": "^6.1.1", "eslint-plugin-react": "^7.33.2", diff --git a/src/client/services/client-network-connector.service.ts b/src/client/services/client-network-connector.service.ts index 65923b2901..4f865dac38 100644 --- a/src/client/services/client-network-connector.service.ts +++ b/src/client/services/client-network-connector.service.ts @@ -9,7 +9,7 @@ import { NetworkConnectorInfo, RequestRouter, } from '@shared/data/internal-connection.model'; -import { Unsubscriber } from '@shared/utils/papi-util'; +import { deserialize, serialize, Unsubscriber } from '@shared/utils/papi-util'; import INetworkConnector from '@shared/services/network-connector.interface'; import { InitClient, @@ -222,7 +222,7 @@ export default class ClientNetworkConnector implements INetworkConnector { this.sendMessage({ type: MessageType.ClientConnect, senderId: this.connectorInfo.clientId, - reconnectingClientGuid, + reconnectingClientGuid: reconnectingClientGuid ?? undefined, }); // Save the new clientGuid so we can check it when reconnecting @@ -329,7 +329,7 @@ export default class ClientNetworkConnector implements INetworkConnector { ); } else { // This message is for someone else. Send the message - this.webSocket.send(JSON.stringify(message)); + this.webSocket.send(serialize(message)); } }; @@ -342,7 +342,7 @@ export default class ClientNetworkConnector implements INetworkConnector { private onMessage = (event: MessageEvent, fromSelf = false) => { // Assert our specific message type. // eslint-disable-next-line no-type-assertion/no-type-assertion - const data: Message = fromSelf ? (event.data as unknown as Message) : JSON.parse(event.data); + const data: Message = fromSelf ? (event.data as unknown as Message) : deserialize(event.data); const emitter = this.messageEmitters.get(data.type); emitter?.emit(data); diff --git a/src/declarations/papi-shared-types.ts b/src/declarations/papi-shared-types.ts index 6f818e333f..ece40e984a 100644 --- a/src/declarations/papi-shared-types.ts +++ b/src/declarations/papi-shared-types.ts @@ -58,7 +58,7 @@ declare module 'papi-shared-types' { // With only one key in this interface, `papi.d.ts` was baking in the literal string when // `SettingNames` was being used. Adding a placeholder key makes TypeScript generate `papi.d.ts` // correctly. When we add another setting, we can remove this placeholder. - placeholder: null; + placeholder: undefined; } export type SettingNames = keyof SettingTypes; diff --git a/src/extension-host/extension-types/extension-manifest.model.ts b/src/extension-host/extension-types/extension-manifest.model.ts index e50452b010..f2d3a0e03f 100644 --- a/src/extension-host/extension-types/extension-manifest.model.ts +++ b/src/extension-host/extension-types/extension-manifest.model.ts @@ -12,9 +12,9 @@ export type ExtensionManifest = { * Path to the JavaScript file to run in the extension host. Relative to the extension's root * folder. * - * Must be specified. Can be `null` if the extension does not have any JavaScript to run. + * Must be specified. Can be an empty string if the extension does not have any JavaScript to run. */ - main: string | null; + main: string; /** * Path to the TypeScript type declaration file that describes this extension and its interactions * on the PAPI. Relative to the extension's root folder. diff --git a/src/extension-host/services/extension-storage.service.ts b/src/extension-host/services/extension-storage.service.ts index 6ab7d505f8..fc395af602 100644 --- a/src/extension-host/services/extension-storage.service.ts +++ b/src/extension-host/services/extension-storage.service.ts @@ -27,6 +27,8 @@ export function setExtensionUris(urisPerExtension: Map) { /** Allow alphanumeric characters and the following: -_.()/\ */ function isValidFileOrDirectoryName(name: string): boolean { + // Regex with no match returns null + // eslint-disable-next-line no-null/no-null return name.match(/^[\w\d-_.()/\\]*$/) !== null; } diff --git a/src/extension-host/services/extension.service.ts b/src/extension-host/services/extension.service.ts index f4aec86fc9..8cee8a5b87 100644 --- a/src/extension-host/services/extension.service.ts +++ b/src/extension-host/services/extension.service.ts @@ -7,7 +7,11 @@ import { IExtension } from '@extension-host/extension-types/extension.interface' import * as nodeFS from '@node/services/node-file-system.service'; import { FILE_PROTOCOL, getPathFromUri, joinUriPaths } from '@node/utils/util'; import { Uri } from '@shared/data/file-system.model'; -import { UnsubscriberAsync, getModuleSimilarApiMessage } from '@shared/utils/papi-util'; +import { + UnsubscriberAsync, + deserialize, + getModuleSimilarApiMessage, +} from '@shared/utils/papi-util'; import Module from 'module'; import * as SillsdevScripture from '@sillsdev/scripture'; import logger from '@shared/services/logger.service'; @@ -112,7 +116,7 @@ let availableExtensions: ExtensionInfo[]; /** Parse string extension manifest into an object and perform any transformations needed */ function parseManifest(extensionManifestJson: string): ExtensionManifest { - const extensionManifest: ExtensionManifest = JSON.parse(extensionManifestJson); + const extensionManifest: ExtensionManifest = deserialize(extensionManifestJson); if (extensionManifest.name.includes('..')) throw new Error('Extension name must not include `..`!'); // Replace ts with js so people can list their source code ts name but run the transpiled js @@ -360,7 +364,7 @@ async function getExtensions(): Promise { // Completely ignore extensions that do not have `main` at all as a hint to developers if (settled.value.main === undefined) { logger.error( - `Extension ${settled.value.name} failed to load. Must provide property \`main\` in \`manifest.json\`. If you do not have JavaScript code to run, provide \`"main": null\``, + `Extension ${settled.value.name} failed to load. Must provide property \`main\` in \`manifest.json\`. If you do not have JavaScript code to run, provide \`"main": ""\``, ); return false; } @@ -665,7 +669,7 @@ async function activateExtensions(extensions: ExtensionInfo[]): Promise activeExtension !== null) as ActiveExtension[]; + ).filter((activeExtension) => activeExtension !== undefined) as ActiveExtension[]; return extensionsActive; } @@ -755,8 +759,8 @@ async function reloadExtensions(shouldDeactivateExtensions: boolean): Promise extension.main !== null); + // If main is an empty string, having no JavaScript is intentional. Do not load this extension + availableExtensions = allExtensions.filter((extension) => extension.main); // Store their base URIs in the extension storage service const uriMap: Map = new Map(); diff --git a/src/extension-host/services/project-lookup.service-host.ts b/src/extension-host/services/project-lookup.service-host.ts index 64f236c7a4..02afcb3ae8 100644 --- a/src/extension-host/services/project-lookup.service-host.ts +++ b/src/extension-host/services/project-lookup.service-host.ts @@ -8,6 +8,7 @@ import { joinUriPaths } from '@node/utils/util'; import logger from '@shared/services/logger.service'; import networkObjectService from '@shared/services/network-object.service'; import * as nodeFS from '@node/services/node-file-system.service'; +import { deserialize } from '@shared/utils/papi-util'; /** This points to the directory where all of the project subdirectories live */ const PROJECTS_ROOT_URI = joinUriPaths('file://', os.homedir(), '.platform.bible', 'projects'); @@ -26,7 +27,7 @@ async function getProjectUris(): Promise { * expect */ function convertToMetadata(jsonString: string): ProjectMetadata { - const md: ProjectMetadata = JSON.parse(jsonString); + const md: ProjectMetadata = deserialize(jsonString); if ('id' in md && 'name' in md && 'storageType' in md && 'projectType' in md) { return md; } @@ -62,7 +63,7 @@ async function getProjectMetadata(projectId: string): Promise { const metadataPath = joinUriPaths(matches[0], METADATA_FILE); const metadataString = await nodeFS.readFileText(metadataPath); - return convertToMetadata(metadataString, projectId); + return convertToMetadata(metadataString); } // Map of project ID to 'meta.json' contents for that project diff --git a/src/main/main.ts b/src/main/main.ts index 9984e494a5..71db24ba2f 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -21,7 +21,7 @@ import networkObjectService from '@shared/services/network-object.service'; import extensionAssetProtocolService from '@main/services/extension-asset-protocol.service'; import { wait } from '@shared/utils/util'; import { CommandNames } from 'papi-shared-types'; -import { SerializedRequestType } from '@shared/utils/papi-util'; +import { SerializedRequestType, serialize } from '@shared/utils/papi-util'; import networkObjectStatusService from '@shared/services/network-object-status.service'; import { startNetworkObjectStatusService } from './services/network-object-status.service-host'; // Used with the commented out code at the bottom of this file to test the ParatextProjectDataProvider @@ -102,7 +102,7 @@ async function main() { // Keep a global reference of the window object. If you don't, the window will // be closed automatically when the JavaScript object is garbage collected. - let mainWindow: BrowserWindow | null = null; + let mainWindow: BrowserWindow | undefined; if (process.env.NODE_ENV === 'production') { const sourceMapSupport = await import('source-map-support'); @@ -176,9 +176,11 @@ async function main() { }); mainWindow.on('closed', () => { - mainWindow = null; + mainWindow = undefined; }); + // 'null' to interact with external API + // eslint-disable-next-line no-null/no-null mainWindow.setMenu(null); // Open urls in the user's browser @@ -250,7 +252,7 @@ async function main() { app.on('activate', () => { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. - if (mainWindow === null) createWindow(); + if (!mainWindow) createWindow(); }); return undefined; @@ -320,7 +322,7 @@ async function main() { setTimeout(async () => { logger.info( - `Available network objects after 30 seconds: ${JSON.stringify( + `Available network objects after 30 seconds: ${serialize( await networkObjectStatusService.getAllNetworkObjectDetails(), )}`, ); diff --git a/src/main/services/server-network-connector.service.ts b/src/main/services/server-network-connector.service.ts index 2eb057648c..01fa9fb627 100644 --- a/src/main/services/server-network-connector.service.ts +++ b/src/main/services/server-network-connector.service.ts @@ -13,7 +13,7 @@ import { } from '@shared/data/internal-connection.model'; import INetworkConnector from '@shared/services/network-connector.interface'; import logger from '@shared/services/logger.service'; -import { Unsubscriber } from '@shared/utils/papi-util'; +import { Unsubscriber, deserialize, serialize } from '@shared/utils/papi-util'; import { ClientConnect, InitClient, @@ -265,7 +265,7 @@ export default class ServerNetworkConnector implements INetworkConnector { * certain clientGuid as connecting clients will often supply old clientGuids. */ private getClientSocketFromGuid = ( - clientGuid: string | undefined | null, + clientGuid: string | undefined, ): WebSocketClient | undefined => { if (!this.webSocketServer) throw new Error('Trying to get client socket when not connected!'); if (!clientGuid) return undefined; @@ -312,7 +312,7 @@ export default class ServerNetworkConnector implements INetworkConnector { ); } else { // This message is for someone else. Send the message - this.getClientSocket(recipientId).webSocket.send(JSON.stringify(message)); + this.getClientSocket(recipientId).webSocket.send(serialize(message)); } }; @@ -327,7 +327,7 @@ export default class ServerNetworkConnector implements INetworkConnector { ? // Assert our specific message type. // eslint-disable-next-line no-type-assertion/no-type-assertion (event.data as unknown as Message) - : JSON.parse(event.data.toString()); + : deserialize(event.data.toString()); // Make sure the client isn't impersonating another client // TODO: consider speeding up validation by passing in webSocket client id? diff --git a/src/node/polyfills/local-storage.polyfill.ts b/src/node/polyfills/local-storage.polyfill.ts index 804f3ab909..eb5395a29b 100644 --- a/src/node/polyfills/local-storage.polyfill.ts +++ b/src/node/polyfills/local-storage.polyfill.ts @@ -4,6 +4,8 @@ import path from 'path'; /** Polyfills LocalStorage into node so you can use localstorage just like in a browser */ const polyfillLocalStorage = () => { + // Polyfill logic needs null + // eslint-disable-next-line no-null/no-null if (typeof localStorage === 'undefined' || localStorage === null) { global.localStorage = new LocalStorage( path.join(getAppDir(), `local-storage/${globalThis.processType}/`), diff --git a/src/renderer/components/basic-list/basic-list.component.tsx b/src/renderer/components/basic-list/basic-list.component.tsx index e86abc8257..56515f3ecf 100644 --- a/src/renderer/components/basic-list/basic-list.component.tsx +++ b/src/renderer/components/basic-list/basic-list.component.tsx @@ -129,7 +129,7 @@ export default function BasicList() { {headerGroup.headers.map((header) => { return ( - {header.isPlaceholder ? null : ( + {header.isPlaceholder ? undefined : (
{flexRender(header.column.columnDef.header, header.getContext())}
)} diff --git a/src/renderer/components/dialogs/dialog-base.data.ts b/src/renderer/components/dialogs/dialog-base.data.ts index d189d65bbd..d9f02878b5 100644 --- a/src/renderer/components/dialogs/dialog-base.data.ts +++ b/src/renderer/components/dialogs/dialog-base.data.ts @@ -2,6 +2,7 @@ import { FloatSize, TabLoader, TabSaver } from '@shared/models/docking-framework import { DialogData } from '@shared/models/dialog-options.model'; import logger from '@shared/services/logger.service'; import { ReactElement, createElement } from 'react'; +import { serialize } from '@shared/utils/papi-util'; /** Base type for DialogDefinition. Contains reasonable defaults for dialogs */ export type DialogDefinitionBase = Readonly<{ @@ -51,7 +52,7 @@ export type DialogProps = DialogData & { * @param data Data with which to resolve the request */ submitDialog(data: TData): void; - /** Cancels the dialog request (resolves the response with `null`) and closes the dialog */ + /** Cancels the dialog request (resolves the response with `undefined`) and closes the dialog */ cancelDialog(): void; /** * Rejects the dialog request with the specified message and closes the dialog @@ -74,9 +75,9 @@ const DIALOG_DEFAULT_SIZE: FloatSize = { width: 300, height: 300 }; * `resolveDialogRequest` in `hookUpDialogService` as soon as possible. This is written this way to * mitigate dependency cycles */ -let resolveDialogRequestInternal = (id: string, data: unknown | null): void => { +let resolveDialogRequestInternal = (id: string, data: unknown | undefined): void => { throw new Error( - `Dialog ${id} tried to resolve before being hooked up to the dialog service! This may indicate that the dialog service started after a dialog was submitted. data: ${JSON.stringify( + `Dialog ${id} tried to resolve before being hooked up to the dialog service! This may indicate that the dialog service started after a dialog was submitted. data: ${serialize( data, )}`, ); @@ -87,7 +88,7 @@ let resolveDialogRequestInternal = (id: string, data: unknown | null): void => { * * This function should just run `dialog.service-host.ts`'s `resolveDialogRequest` */ -function resolveDialogRequest(id: string, data: unknown | null) { +function resolveDialogRequest(id: string, data: unknown | undefined) { return resolveDialogRequestInternal(id, data); } @@ -100,7 +101,7 @@ function resolveDialogRequest(id: string, data: unknown | null) { */ let rejectDialogRequestInternal = (id: string, message: string): void => { throw new Error( - `Dialog ${id} tried to reject before being hooked up to the dialog service! This may indicate that the dialog service started after a dialog was canceled. message: ${JSON.stringify( + `Dialog ${id} tried to reject before being hooked up to the dialog service! This may indicate that the dialog service started after a dialog was canceled. message: ${serialize( message, )}`, ); @@ -127,7 +128,7 @@ export function hookUpDialogService({ resolveDialogRequest: resolve, rejectDialogRequest: reject, }: { - resolveDialogRequest: (id: string, data: unknown | null) => void; + resolveDialogRequest: (id: string, data: unknown | undefined) => void; rejectDialogRequest: (id: string, message: string) => void; }) { resolveDialogRequestInternal = resolve; @@ -155,7 +156,7 @@ const DIALOG_BASE: DialogDefinitionBase = { logger.error( `Dialog ${ this.tabType - } received savedTabInfo without data.isDialog! Please investigate. This could be a sign of a problem, but we will try to move forward for now. savedTabInfo: ${JSON.stringify( + } received savedTabInfo without data.isDialog! Please investigate. This could be a sign of a problem, but we will try to move forward for now. savedTabInfo: ${serialize( savedTabInfo, )}`, ); @@ -175,7 +176,7 @@ const DIALOG_BASE: DialogDefinitionBase = { content: createElement(this.Component!, { ...tabData, submitDialog: (data) => resolveDialogRequest(savedTabInfo.id, data), - cancelDialog: () => resolveDialogRequest(savedTabInfo.id, null), + cancelDialog: () => resolveDialogRequest(savedTabInfo.id, undefined), rejectDialog: (errorMessage) => rejectDialogRequest(savedTabInfo.id, errorMessage), }), }; diff --git a/src/renderer/components/docking/platform-dock-layout.component.tsx b/src/renderer/components/docking/platform-dock-layout.component.tsx index cf7479ca05..f5268532fb 100644 --- a/src/renderer/components/docking/platform-dock-layout.component.tsx +++ b/src/renderer/components/docking/platform-dock-layout.component.tsx @@ -237,8 +237,8 @@ function saveTab(dockTabInfo: RCDockTabInfo): SavedTabInfo | undefined { * @returns `true` if its a tab or `false` otherwise. */ function isTab(tab: PanelData | TabData | BoxData | undefined): tab is TabData { - // Assert the more specific type. - // eslint-disable-next-line no-type-assertion/no-type-assertion + // Assert the more specific type. Null to work with the external API. + // eslint-disable-next-line no-type-assertion/no-type-assertion, no-null/no-null if (!tab || (tab as TabData).title == null) return false; return true; } @@ -380,6 +380,8 @@ export function addTabToDock( dockLayout.dockMove( tab, // Find the first thing (the dock box) and add the tab to it + // Null required by the external API + // eslint-disable-next-line no-null/no-null dockLayout.find(() => true) ?? null, 'middle', ); @@ -397,6 +399,8 @@ export function addTabToDock( // Update the previous float position so the next cascading float layout will appear after it previousFloatPosition = floatPosition; + // Null required by the external API + // eslint-disable-next-line no-null/no-null dockLayout.dockMove(tab, null, 'float', floatPosition); break; } @@ -419,6 +423,8 @@ export function addTabToDock( (targetTab?.parent as PanelData) ?? // Otherwise find the first thing (the dock box) and add the tab to it dockLayout.find(() => true) ?? + // Null required by the external API + // eslint-disable-next-line no-null/no-null null, // Defaults are added in `layoutDefaults`. // eslint-disable-next-line no-type-assertion/no-type-assertion @@ -579,7 +585,7 @@ function updateWebViewDefinition( export default function PlatformDockLayout() { // This ref will always be defined // eslint-disable-next-line no-type-assertion/no-type-assertion - const dockLayoutRef = useRef(null!); + const dockLayoutRef = useRef(undefined!); /** * OnLayoutChange function from `web-view.service.ts` once this docklayout is registered. @@ -600,6 +606,8 @@ export default function PlatformDockLayout() { addWebViewToDock(webView, layout, dockLayoutRef.current), removeTabFromDock: (tabId: string) => { const tabToRemove = dockLayoutRef.current.find(tabId); + // Null required by the external API + // eslint-disable-next-line no-null/no-null if (isTab(tabToRemove)) dockLayoutRef.current.dockMove(tabToRemove, null, 'remove'); // Return whether or not we found the tab to remove return !!tabToRemove; @@ -639,7 +647,7 @@ export default function PlatformDockLayout() { const removedTab = dockLayoutRef.current.find(currentTabId) as RCDockTabInfo; if ((removedTab.data as DialogData)?.isDialog && hasDialogRequest(currentTabId)) /* eslint-enable */ - resolveDialogRequest(currentTabId, null, false); + resolveDialogRequest(currentTabId, undefined, false); } (async () => { diff --git a/src/renderer/components/extension-manager/extension-card.component.tsx b/src/renderer/components/extension-manager/extension-card.component.tsx index 526ff19974..7e74ed4b8e 100644 --- a/src/renderer/components/extension-manager/extension-card.component.tsx +++ b/src/renderer/components/extension-manager/extension-card.component.tsx @@ -30,7 +30,7 @@ export default function ExtensionCard({ const avatar = useMemo( () => ( - {!iconFilePath ? extensionName[0] : null} + {!iconFilePath ? extensionName[0] : undefined} ), [extensionDescription, extensionName, iconFilePath], @@ -49,14 +49,14 @@ export default function ExtensionCard({ title={extensionName} titleTypographyProps={{ variant: 'h6' }} action={headerAction} - subheader={!isGallery ? extensionDescription : null} + subheader={!isGallery ? extensionDescription : undefined} subheaderTypographyProps={{ variant: 'body2' }} /> {isGallery ? ( {extensionDescription} - ) : null} + ) : undefined} {children} diff --git a/src/renderer/components/extension-manager/extension-list.component.tsx b/src/renderer/components/extension-manager/extension-list.component.tsx index 91c8108a40..092874f3ae 100644 --- a/src/renderer/components/extension-manager/extension-list.component.tsx +++ b/src/renderer/components/extension-manager/extension-list.component.tsx @@ -80,7 +80,7 @@ export default function ExtensionList({ - ) : null} + ) : undefined} {children} ))} diff --git a/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx b/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx index e742cfc16a..a374f350c9 100644 --- a/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx +++ b/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx @@ -17,7 +17,7 @@ export const TAB_TYPE_RUN_BASIC_CHECKS = 'run-basic-checks'; // Changing global scripture reference won't effect the dialog because reference is passed in once at the start. type RunBasicChecksTabProps = { - currentScriptureReference: ScriptureReference | null; + currentScriptureReference: ScriptureReference | undefined; currentProjectId: string | undefined; }; diff --git a/src/renderer/components/web-view.component.tsx b/src/renderer/components/web-view.component.tsx index 53be8fe166..7194f0f9df 100644 --- a/src/renderer/components/web-view.component.tsx +++ b/src/renderer/components/web-view.component.tsx @@ -15,6 +15,7 @@ import { WEBVIEW_IFRAME_SRCDOC_SANDBOX, } from '@renderer/services/web-view.service-host'; import logger from '@shared/services/logger.service'; +import { serialize } from '@shared/utils/papi-util'; export const TAB_TYPE_WEBVIEW = 'webView'; @@ -32,7 +33,7 @@ export default function WebView({ }: WebViewTabProps) { // This ref will always be defined // eslint-disable-next-line no-type-assertion/no-type-assertion - const iframeRef = useRef(null!); + const iframeRef = useRef(undefined!); /** Whether this webview's iframe will be populated by `src` as opposed to `srcdoc` */ const shouldUseSrc = contentType === WebViewContentType.URL; @@ -121,7 +122,7 @@ export function loadWebViewTab(savedTabInfo: SavedTabInfo): TabInfo { await retrieveWebViewContent(data); } catch (e) { logger.error( - `web-view.component failed to retrieve web view content for ${JSON.stringify( + `web-view.component failed to retrieve web view content for ${serialize( savedTabInfo, )}: ${e}`, ); diff --git a/src/renderer/context/papi-context/test.context.ts b/src/renderer/context/papi-context/test.context.ts index 0cf8a0bc56..c11b107fdc 100644 --- a/src/renderer/context/papi-context/test.context.ts +++ b/src/renderer/context/papi-context/test.context.ts @@ -1,6 +1,4 @@ import { createContext } from 'react'; -// This should always be defined, so non-null the value -// eslint-disable-next-line no-type-assertion/no-type-assertion -const TestContext = createContext(undefined!); +const TestContext = createContext(''); export default TestContext; diff --git a/src/renderer/hooks/papi-hooks/use-dialog-callback.hook.ts b/src/renderer/hooks/papi-hooks/use-dialog-callback.hook.ts index e001684c31..993e4fb635 100644 --- a/src/renderer/hooks/papi-hooks/use-dialog-callback.hook.ts +++ b/src/renderer/hooks/papi-hooks/use-dialog-callback.hook.ts @@ -13,8 +13,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; * * @type `DialogTabType` The dialog type you are using. Should be inferred by parameters * @type `TResponse` The type that the response can be. If you do not specify a `defaultResponse`, - * this can be the dialog response type or `null`. If you specify a `defaultResponse`, this will - * be just the dialog response type. Should be inferred by parameters. + * this can be the dialog response type or `undefined`. If you specify a `defaultResponse`, this + * will be just the dialog response type. Should be inferred by parameters. * * - This mostly works. Unfortunately, if you specify a literal as `defaultResponse`, `TResponse` then * becomes that literal instead of being the dialog response type. You can type assert it to @@ -29,7 +29,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; * WARNING: MUST BE STABLE - const or wrapped in useState, useMemo, etc. The reference must not be * updated every render * @param defaultResponse The starting value for the response. Once a response is received, this is - * no longer used. Defaults to `null` + * no longer used. Defaults to `undefined` * @returns `[response, showDialogCallback, errorMessage, isShowingDialog]` * * - `response` - the response from the dialog or `defaultResponse` if a response has not been @@ -44,17 +44,17 @@ import { useCallback, useEffect, useRef, useState } from 'react'; */ function useDialogCallback< DialogTabType extends DialogTabTypes, - TResponse extends DialogTypes[DialogTabType]['responseType'] | null = + TResponse extends DialogTypes[DialogTabType]['responseType'] | undefined = | DialogTypes[DialogTabType]['responseType'] - | null, + | undefined, >( dialogType: DialogTabType, options?: DialogTypes[DialogTabType]['options'], - // Since `defaultResponse` could be unspecified which is equivalent to `null`, we need to - // type assert to tell TS that `null` will be part of `TResponse` if `defaultResponse` is not + // Since `defaultResponse` could be unspecified which is equivalent to `undefined`, we need to + // type assert to tell TS that `undefined` will be part of `TResponse` if `defaultResponse` is not // specified but is not otherwise // eslint-disable-next-line no-type-assertion/no-type-assertion - defaultResponse: TResponse = null as TResponse, + defaultResponse: TResponse = undefined as TResponse, ): [TResponse, () => Promise, string | undefined, boolean] { // Keep track of whether we're mounted so we don't run stuff after unmount const mounted = useRef(false); @@ -76,12 +76,11 @@ function useDialogCallback< // Looks like we need to type assert here because it can't tell this is a TResponse. It can // just tell that it is the dialog response type, which does not include undefined // eslint-disable-next-line no-type-assertion/no-type-assertion - const dialogResponse = (await dialogService.showDialog( - dialogType, - options, - )) as TResponse | null; + const dialogResponse = (await dialogService.showDialog(dialogType, options)) as + | TResponse + | undefined; if (mounted.current) { - if (dialogResponse !== null) + if (dialogResponse !== undefined) // For now, let's only set the response value if the user didn't cancel. Maybe we can // expose an option for configuring this hook later if people want to reset to // `defaultResponse` on canceling the dialog diff --git a/src/renderer/hooks/papi-hooks/use-promise.hook.ts b/src/renderer/hooks/papi-hooks/use-promise.hook.ts index d355b3ad5d..b330f62c18 100644 --- a/src/renderer/hooks/papi-hooks/use-promise.hook.ts +++ b/src/renderer/hooks/papi-hooks/use-promise.hook.ts @@ -3,10 +3,9 @@ import { useEffect, useState } from 'react'; /** * Awaits a promise and returns a loading value while the promise is unresolved * - * @param promiseFactoryCallback A function that returns the promise to await. If the promise - * resolves to null, the value will not change. If this callback is undefined, the current value - * will be returned (defaultValue unless it was previously changed and preserveValue is true), and - * there will be no loading. + * @param promiseFactoryCallback A function that returns the promise to await. If this callback is + * undefined, the current value will be returned (defaultValue unless it was previously changed + * and preserveValue is true), and there will be no loading. * * WARNING: MUST BE STABLE - const or wrapped in useCallback. The reference must not be updated * every render @@ -24,7 +23,7 @@ import { useEffect, useState } from 'react'; * - `isLoading`: whether the promise is waiting to be resolved */ const usePromise = ( - promiseFactoryCallback: (() => Promise) | undefined, + promiseFactoryCallback: (() => Promise) | undefined, defaultValue: T, preserveValue = true, ): [value: T, isLoading: boolean] => { @@ -40,8 +39,7 @@ const usePromise = ( const result = await promiseFactoryCallback(); // If the promise was not already replaced, update the value if (promiseIsCurrent) { - // If the promise returned null, it purposely did this to do nothing. Maybe its dependencies are not set up - if (result !== null) setValue(() => result); + setValue(() => result); setIsLoading(false); } } diff --git a/src/renderer/hooks/papi-hooks/use-setting.hook.ts b/src/renderer/hooks/papi-hooks/use-setting.hook.ts index 1fbdc9d936..50baa4fd2f 100644 --- a/src/renderer/hooks/papi-hooks/use-setting.hook.ts +++ b/src/renderer/hooks/papi-hooks/use-setting.hook.ts @@ -6,7 +6,7 @@ import { SettingNames, SettingTypes } from 'papi-shared-types'; * Gets and sets a setting on the papi. Also notifies subscribers when the setting changes and gets * updated when the setting is changed by others. * - * Setting the value to `null` is the equivalent of deleting the setting. + * Setting the value to `undefined` is the equivalent of deleting the setting. * * @param key The string id that is used to store the setting in local storage * @@ -29,12 +29,12 @@ const useSetting = ( ): [SettingTypes[SettingName], (newSetting: SettingTypes[SettingName]) => void] => { const [setting, setSettingInternal] = useState(() => { const initialSetting = settingsService.get(key); - return initialSetting !== null ? initialSetting : defaultState; + return initialSetting !== undefined ? initialSetting : defaultState; }); useEffect(() => { - const updateSettingFromService = (newSetting: SettingTypes[SettingName] | null) => { - if (newSetting !== null) { + const updateSettingFromService = (newSetting: SettingTypes[SettingName] | undefined) => { + if (newSetting !== undefined) { setSettingInternal(newSetting); } else { setSettingInternal(defaultState); @@ -54,9 +54,9 @@ const useSetting = ( }, [key, defaultState]); const setSetting = useCallback( - (newSetting: SettingTypes[SettingName] | null) => { + (newSetting: SettingTypes[SettingName] | undefined) => { settingsService.set(key, newSetting); - setSettingInternal(newSetting !== null ? newSetting : defaultState); + setSettingInternal(newSetting !== undefined ? newSetting : defaultState); }, [key, defaultState], ); diff --git a/src/renderer/services/dialog.service-host.ts b/src/renderer/services/dialog.service-host.ts index c5a201818d..cda5453859 100644 --- a/src/renderer/services/dialog.service-host.ts +++ b/src/renderer/services/dialog.service-host.ts @@ -1,7 +1,11 @@ import { DialogData } from '@shared/models/dialog-options.model'; import { CATEGORY_DIALOG, DialogService } from '@shared/services/dialog.service-model'; import * as networkService from '@shared/services/network.service'; -import { aggregateUnsubscriberAsyncs, serializeRequestType } from '@shared/utils/papi-util'; +import { + aggregateUnsubscriberAsyncs, + serialize, + serializeRequestType, +} from '@shared/utils/papi-util'; import * as webViewService from '@renderer/services/web-view.service-host'; import { newGuid } from '@shared/utils/util'; import logger from '@shared/services/logger.service'; @@ -15,8 +19,8 @@ type DialogRequest = { id: string; resolve: ( value: - | (DialogTypes[DialogTabType]['responseType'] | null) - | PromiseLike, + | (DialogTypes[DialogTabType]['responseType'] | undefined) + | PromiseLike, ) => void; reject: (reason?: unknown) => void; }; @@ -54,7 +58,7 @@ export function hasDialogRequest(id: string) { * * @param id The id of the dialog whose request to reject * @param data The data to resolve the request with. Either the user's response to the dialog or - * `null` if the user canceled + * `undefined` if the user canceled * @param shouldCloseDialog Whether we should close the dialog in this function. Should probably * only be `false` if the dialog is already being closed another way such as in * `platform-dock-layout.component.tsx`. Defaults to true @@ -62,7 +66,7 @@ export function hasDialogRequest(id: string) { */ export function resolveDialogRequest( id: string, - data: TReturn | null, + data: TReturn | undefined, shouldCloseDialog = true, ) { const dialogRequest = dialogRequests.get(id); @@ -81,13 +85,13 @@ export function resolveDialogRequest( const didClose = await webViewService.removeTab(id); if (!didClose) logger.error( - `DialogService error: dialog ${id} that was resolved with data ${JSON.stringify( + `DialogService error: dialog ${id} that was resolved with data ${serialize( data, )} was not found in the dock layout in order to close. Please investigate`, ); } catch (e) { logger.error( - `DialogService error: dialog ${id} that was resolved with data ${JSON.stringify( + `DialogService error: dialog ${id} that was resolved with data ${serialize( data, )} did not successfully close! Please investigate. Error: ${e}`, ); @@ -98,7 +102,7 @@ export function resolveDialogRequest( // If we didn't find the request, throw if (!dialogRequest) throw new Error( - `DialogService error: request ${id} not found to resolve. data: ${JSON.stringify(data)}`, + `DialogService error: request ${id} not found to resolve. data: ${serialize(data)}`, ); } @@ -143,7 +147,7 @@ export function rejectDialogRequest(id: string, message: string) { async function showDialog( dialogType: DialogTabType, options?: DialogTypes[DialogTabType]['options'], -): Promise { +): Promise { await initialize(); // Set up a DialogRequest @@ -153,7 +157,7 @@ async function showDialog( let dialogRequest: DialogRequest; - const dialogPromise = new Promise( + const dialogPromise = new Promise( (resolve, reject) => { dialogRequest = { id: dialogId, @@ -195,7 +199,7 @@ async function showDialog( // on the dialogService - see `dialog.service-model.ts` for JSDoc async function selectProject( options?: DialogTypes[typeof SELECT_PROJECT_DIALOG.tabType]['options'], -): Promise { +): Promise { return showDialog(SELECT_PROJECT_DIALOG.tabType, options); } diff --git a/src/renderer/services/web-view-state.service.ts b/src/renderer/services/web-view-state.service.ts index 27c2f1cb00..6176d3d54e 100644 --- a/src/renderer/services/web-view-state.service.ts +++ b/src/renderer/services/web-view-state.service.ts @@ -1,4 +1,4 @@ -import { isSerializable } from '@shared/utils/papi-util'; +import { deserialize, isSerializable, serialize } from '@shared/utils/papi-util'; const WEBVIEW_STATE_KEY = 'web-view-state'; const stateMap = new Map>(); @@ -11,7 +11,7 @@ function loadIfNeeded(): void { const serializedState = localStorage.getItem(WEBVIEW_STATE_KEY); if (!serializedState) return; - const entries: [[string, Record]] = JSON.parse(serializedState); + const entries: [[string, Record]] = deserialize(serializedState); entries.forEach(([key, value]) => { if (key && value) stateMap.set(key, value); }); @@ -21,7 +21,7 @@ function save(): void { // If no one looked anything up, don't overwrite anything if (idsLookedUp.size <= 0) return; - const stateToSave = JSON.stringify(Array.from(stateMap.entries())); + const stateToSave = serialize(Array.from(stateMap.entries())); localStorage.setItem(WEBVIEW_STATE_KEY, stateToSave); } @@ -85,12 +85,12 @@ export function getWebViewStateById(id: string, stateKey: string): T | undefi * @param id ID of the web view * @param stateKey Key for the associated state * @param stateValue Value of the state for the given key of the given web view - must work with - * JSON.stringify/parse + * serialize/deserialize */ export function setWebViewStateById(id: string, stateKey: string, stateValue: T): void { if (!id || !stateKey) throw new Error('id and stateKey must be provided to set webview state'); if (!isSerializable(stateValue)) - throw new Error(`"${stateKey}" value cannot round trip with JSON.stringify and JSON.parse.`); + throw new Error(`"${stateKey}" value cannot round trip with serialize and deserialize.`); const state = getRecord(id); state[stateKey] = stateValue; diff --git a/src/renderer/services/web-view.service-host.ts b/src/renderer/services/web-view.service-host.ts index 11d531fea8..5a1e5eceb3 100644 --- a/src/renderer/services/web-view.service-host.ts +++ b/src/renderer/services/web-view.service-host.ts @@ -5,7 +5,7 @@ * for services in the renderer to call. */ import cloneDeep from 'lodash/cloneDeep'; -import { Unsubscriber } from '@shared/utils/papi-util'; +import { Unsubscriber, deserialize, serialize } from '@shared/utils/papi-util'; import { isString, newGuid, newNonce } from '@shared/utils/util'; import { createNetworkEventEmitter } from '@shared/services/network.service'; import { @@ -494,7 +494,7 @@ async function loadLayout(layout?: LayoutBase): Promise { */ function getStorageValue(key: string, defaultValue: T): T { const saved = localStorage.getItem(key); - const initial = saved ? JSON.parse(saved) : undefined; + const initial = saved ? deserialize(saved) : undefined; return initial || defaultValue; } @@ -505,7 +505,7 @@ function getStorageValue(key: string, defaultValue: T): T { */ async function saveLayout(layout: LayoutBase): Promise { const currentLayout = layout; - localStorage.setItem(DOCK_LAYOUT_KEY, JSON.stringify(currentLayout)); + localStorage.setItem(DOCK_LAYOUT_KEY, serialize(currentLayout)); } /** @@ -737,15 +737,9 @@ globalThis.updateWebViewDefinitionById = updateWebViewDefinitionSync; */ export const getWebView = async ( webViewType: WebViewType, - layout?: Layout, - options?: GetWebViewOptions, + layout: Layout = { type: 'tab' }, + options: GetWebViewOptions = {}, ): Promise => { - // Parameter defaulting doesn't work with network objects, so do it first thing here - /* eslint-disable no-param-reassign */ - if (!layout) layout = { type: 'tab' }; - if (!options) options = {}; - /* eslint-enable no-param-reassign */ - const optionsDefaulted = getWebViewOptionsDefaults(options); // ENHANCEMENT: If they aren't looking for an existingId, we could get the webview without // searching for an existing webview and send it to the renderer, skipping the part where we send diff --git a/src/renderer/testing/test-buttons-panel.component.tsx b/src/renderer/testing/test-buttons-panel.component.tsx index d2d7310097..b56a8f1f9e 100644 --- a/src/renderer/testing/test-buttons-panel.component.tsx +++ b/src/renderer/testing/test-buttons-panel.component.tsx @@ -9,6 +9,7 @@ import { SavedTabInfo, TabInfo } from '@shared/models/docking-framework.model'; import useEvent from '@renderer/hooks/papi-hooks/use-event.hook'; import useData from '@renderer/hooks/papi-hooks/use-data.hook'; import useDataProvider from '@renderer/hooks/papi-hooks/use-data-provider.hook'; +import { serialize } from '@shared/utils/papi-util'; export const TAB_TYPE_BUTTONS = 'buttons'; @@ -86,7 +87,7 @@ const executeMany = async (fn: () => Promise) => { export default function TestButtonsPanel() { const [promiseReturn, setPromiseReturn] = useState('Click a button.'); const updatePromiseReturn = useCallback( - (state: unknown) => setPromiseReturn(isString(state) ? state : JSON.stringify(state)), + (state: unknown) => setPromiseReturn(isString(state) ? state : serialize(state)), [], ); diff --git a/src/shared/data/network-connector.model.ts b/src/shared/data/network-connector.model.ts index ae6f778661..ac775cc558 100644 --- a/src/shared/data/network-connector.model.ts +++ b/src/shared/data/network-connector.model.ts @@ -54,7 +54,7 @@ export type ClientConnect = { * unregister all requests on that client so the reconnecting client can register its request * handlers again. */ - reconnectingClientGuid?: string | null; + reconnectingClientGuid?: string; }; /** Request to do something and to respond */ diff --git a/src/shared/services/dialog.service-model.ts b/src/shared/services/dialog.service-model.ts index 0b74a92adf..97a314b8cb 100644 --- a/src/shared/services/dialog.service-model.ts +++ b/src/shared/services/dialog.service-model.ts @@ -13,22 +13,19 @@ export interface DialogService { * @type `TReturn` - The type of data the dialog responds with * @param dialogType The type of dialog to show the user * @param options Various options for configuring the dialog that shows - * @returns Returns the user's response or `null` if the user cancels - * - * Note: canceling responds with `null` instead of `undefined` so that the dialog definition can - * use `undefined` as a meaningful value if desired. + * @returns Returns the user's response or `undefined` if the user cancels */ showDialog( dialogType: DialogTabType, options?: DialogTypes[DialogTabType]['options'], - ): Promise; + ): Promise; /** * Shows a select project dialog to the user and prompts the user to select a dialog * * @param options Various options for configuring the dialog that shows - * @returns Returns the user's selected project id or `null` if the user cancels + * @returns Returns the user's selected project id or `undefined` if the user cancels */ - selectProject(options?: DialogOptions): Promise; + selectProject(options?: DialogOptions): Promise; } /** Prefix on requests that indicates that the request is related to dialog operations */ diff --git a/src/shared/services/graphql.service.ts b/src/shared/services/graphql.service.ts index f4dd679d1b..1545f87e94 100644 --- a/src/shared/services/graphql.service.ts +++ b/src/shared/services/graphql.service.ts @@ -4,6 +4,7 @@ import projectLookupService from '@shared/services/project-lookup.service'; import { ProjectMetadata } from '@shared/models/project-metadata.model'; import { ProjectDataProvider } from '@shared/models/project-data-provider-engine.model'; import { get } from '@shared/services/project-data-provider.service'; +import { serialize } from '@shared/utils/papi-util'; // TODO: figure out what to do with the schema. It's a baked in string just to get things rolling, not because it's optimal. const usfmSchema = buildSchema(` @@ -118,7 +119,7 @@ async function runQuery(query: string): Promise( // Notify that the network object was successfully registered // eslint-disable-next-line no-type-assertion/no-type-assertion const netObjDetails = createNetworkObjectDetails(id, objectToShare as Record); - logger.info(`Network object registered: ${JSON.stringify(netObjDetails)}`); + logger.info(`Network object registered: ${serialize(netObjDetails)}`); onDidCreateNetworkObjectEmitter.emit(netObjDetails); // Override objectToShare's type's force-undefined onDidDispose to DisposableNetworkObject's diff --git a/src/shared/services/settings.service.ts b/src/shared/services/settings.service.ts index c0580c8233..479f6a0586 100644 --- a/src/shared/services/settings.service.ts +++ b/src/shared/services/settings.service.ts @@ -1,45 +1,46 @@ -import { Unsubscriber } from '@shared/utils/papi-util'; +import { Unsubscriber, deserialize, serialize } from '@shared/utils/papi-util'; import PapiEventEmitter from '@shared/models/papi-event-emitter.model'; import { SettingNames, SettingTypes } from 'papi-shared-types'; -type Nullable = T | null; - /** All message subscriptions - emitters that emit an event each time a setting is updated */ const onDidUpdateSettingEmitters = new Map< SettingNames, - PapiEventEmitter> + PapiEventEmitter >(); /** * Retrieves the value of the specified setting * * @param key The string id of the setting for which the value is being retrieved - * @returns The value of the specified setting, parsed to an object. Returns `null` if setting is - * not present or no value is available + * @returns The value of the specified setting, parsed to an object. Returns `undefined` if setting + * is not present or no value is available */ const getSetting = ( key: SettingName, -): Nullable => { +): SettingTypes[SettingName] | undefined => { const settingString = localStorage.getItem(key); - return settingString !== null ? JSON.parse(settingString) : null; + // Null is used by the external API + // eslint-disable-next-line no-null/no-null + return settingString !== null ? deserialize(settingString) : undefined; }; /** * Sets the value of the specified setting * * @param key The string id of the setting for which the value is being retrieved - * @param newSetting The value that is to be stored. Setting the new value to `null` is the + * @param newSetting The value that is to be stored. Setting the new value to `undefined` is the * equivalent of deleting the setting */ const setSetting = ( key: SettingName, - newSetting: Nullable, + newSetting: SettingTypes[SettingName] | undefined, ) => { - localStorage.setItem(key, JSON.stringify(newSetting)); + if (newSetting === undefined) localStorage.removeItem(key); + else localStorage.setItem(key, serialize(newSetting)); // Assert type of the particular SettingName of the emitter. // eslint-disable-next-line no-type-assertion/no-type-assertion const emitter = onDidUpdateSettingEmitters.get(key) as - | PapiEventEmitter> + | PapiEventEmitter | undefined; emitter?.emit(newSetting); }; @@ -54,18 +55,21 @@ const setSetting = ( */ const subscribeToSetting = ( key: SettingName, - callback: (newSetting: Nullable) => void, + callback: (newSetting: SettingTypes[SettingName] | undefined) => void, ): Unsubscriber => { // Assert type of the particular SettingName of the emitter. // eslint-disable-next-line no-type-assertion/no-type-assertion let emitter = onDidUpdateSettingEmitters.get(key) as - | PapiEventEmitter> + | PapiEventEmitter | undefined; if (!emitter) { - emitter = new PapiEventEmitter>(); - // Assert type of the general SettingTypes of the emitter. - // eslint-disable-next-line no-type-assertion/no-type-assertion - onDidUpdateSettingEmitters.set(key, emitter as PapiEventEmitter>); + emitter = new PapiEventEmitter(); + onDidUpdateSettingEmitters.set( + key, + // Assert type of the general SettingTypes of the emitter. + // eslint-disable-next-line no-type-assertion/no-type-assertion + emitter as PapiEventEmitter, + ); } return emitter.subscribe(callback); }; diff --git a/src/shared/utils/async-variable.ts b/src/shared/utils/async-variable.ts index 2ced0c99fa..318e06a25d 100644 --- a/src/shared/utils/async-variable.ts +++ b/src/shared/utils/async-variable.ts @@ -4,8 +4,8 @@ import logger from '@shared/services/logger.service'; export default class AsyncVariable { private readonly variableName: string; private readonly promiseToValue: Promise; - private resolver: ((value: T) => void) | null = null; - private rejecter: ((reason: string | undefined) => void) | null = null; + private resolver: ((value: T) => void) | undefined; + private rejecter: ((reason: string | undefined) => void) | undefined; /** * Creates an instance of the class @@ -89,8 +89,8 @@ export default class AsyncVariable { /** Prevent any further updates to this variable */ private complete(): void { - this.resolver = null; - this.rejecter = null; + this.resolver = undefined; + this.rejecter = undefined; Object.freeze(this); } } diff --git a/src/shared/utils/papi-util.test.ts b/src/shared/utils/papi-util.test.ts index 864b6687a0..e4bbd05af9 100644 --- a/src/shared/utils/papi-util.test.ts +++ b/src/shared/utils/papi-util.test.ts @@ -1,6 +1,10 @@ +// When making tests, we need to explicitly use null many times +/* eslint-disable no-null/no-null */ import { SerializedRequestType, deepEqual, + serialize, + deserialize, deserializeRequestType, isSerializable, serializeRequestType, @@ -15,7 +19,7 @@ class Stuff { } } -describe('PAPI Utils', () => { +describe('PAPI Util Functions: serializeRequestType and deserializeRequestType', () => { it('can serialize and deserialize request types', () => { const CATEGORY = 'myCategory'; const DIRECTIVE = 'myDirective'; @@ -30,7 +34,7 @@ describe('PAPI Utils', () => { expect(directive).toEqual(DIRECTIVE); }); - it('can deserialize with more than one separator', () => { + it('can deserialize with more than one separator in request types', () => { const CATEGORY = 'myCategory'; const DIRECTIVE = 'myDirective:subDirective'; @@ -44,14 +48,14 @@ describe('PAPI Utils', () => { expect(directive).toEqual(DIRECTIVE); }); - it('will throw on deserialize with no separator', () => { + it('will throw on deserialize with no separator in request types', () => { const CATEGORY = 'myCategory'; // eslint-disable-next-line no-type-assertion/no-type-assertion expect(() => deserializeRequestType(CATEGORY as SerializedRequestType)).toThrow(); }); - it('will throw on serialize if either input is undefined or empty', () => { + it('will throw on serialize if either input is undefined or empty for request types', () => { const CATEGORY = 'myCategory'; const DIRECTIVE = 'myDirective'; // eslint-disable-next-line no-type-assertion/no-type-assertion @@ -68,7 +72,7 @@ describe('PAPI Utils', () => { }); }); -describe('deepEqual', () => { +describe('PAPI Util Function: deepEqual', () => { it('is true for empty objects', () => { expect(deepEqual({}, {})).toBeTruthy(); }); @@ -203,7 +207,63 @@ describe('deepEqual', () => { }); }); -describe('isSerializable', () => { +describe('PAPI Util Functions: serialize and deserialize', () => { + it('handle values without null or undefined the same as JSON.stringify/parse', () => { + const testObject = { foo: 'fooValue', bar: 3, baz: { bazInternal: 'LOL' } }; + const serializedTestObject = JSON.stringify(testObject); + expect(serialize(testObject)).toEqual(JSON.stringify(testObject)); + expect( + deepEqual(deserialize(serializedTestObject), JSON.parse(serializedTestObject)), + ).toBeTruthy(); + expect(serialize(5)).toEqual(JSON.stringify(5)); + 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(); + }); + 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' } } } } }; + expect(deepEqual(roundTrip, deserialize(serialize(deepNesting)))).toBeTruthy(); + }); + it('work with custom replacers/revivers', () => { + const testObject = { a: 5 }; + const replacer = (_key: string, value: unknown) => { + if (value === 5) return 10; + return value; + }; + const reviver = (_key: string, value: unknown) => { + if (value === 10) return 5; + if (value === undefined) 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'); + }); + it('turn null values in an array into undefined when deserializing', () => { + // Type asserting after deserializing + // eslint-disable-next-line no-type-assertion/no-type-assertion, @typescript-eslint/no-explicit-any + const transformedArray = deserialize(serialize([1, undefined, null, 4])) as Array; + expect(transformedArray[0]).toEqual(1); + expect(transformedArray[1]).toEqual(undefined); + expect(transformedArray[2]).toEqual(undefined); + expect(transformedArray[3]).toEqual(4); + }); +}); + +describe('PAPI Util Function: isSerializable', () => { it('successfully determines empty object is serializable', () => { const objectToSerialize = {}; expect(isSerializable(objectToSerialize)).toBeTruthy(); @@ -224,9 +284,9 @@ describe('isSerializable', () => { expect(isSerializable(objectToSerialize)).toBeTruthy(); }); - it('successfully determines `undefined` is not serializable', () => { + it('successfully determines `undefined` is serializable', () => { const objectToSerialize = undefined; - expect(isSerializable(objectToSerialize)).toBeFalsy(); + expect(isSerializable(objectToSerialize)).toBeTruthy(); }); it('successfully determines deep object with some simple properties is serializable', () => { @@ -242,6 +302,11 @@ describe('isSerializable', () => { expect(isSerializable(objectToSerialize)).toBeTruthy(); }); + it('successfully handles undefined and null items in an array', () => { + const objectToSerialize = [1, undefined, null, 4]; + expect(isSerializable(objectToSerialize)).toBeTruthy(); + }); + it('successfully determines an array created in a different iframe is serializable', () => { const objectToSerialize = [3, 'stuff', { hi: 'yes' }]; // Simulate getting this array from another iframe @@ -261,14 +326,16 @@ describe('isSerializable', () => { expect(isSerializable(objectToSerialize)).toBeTruthy(); }); - it('successfully determines object with `undefined` prop is serializable', () => { + 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 const objectToSerialize = { stuff: undefined, things: true }; - expect(isSerializable(objectToSerialize)).toBeTruthy(); + expect(isSerializable(objectToSerialize)).toBeFalsy(); }); - it('successfully determines object with `null` prop is serializable', () => { + 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 const objectToSerialize = { stuff: null, things: true }; - expect(isSerializable(objectToSerialize)).toBeTruthy(); + expect(isSerializable(objectToSerialize)).toBeFalsy(); }); 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 dd9bba4f9d..a1eea87fc8 100644 --- a/src/shared/utils/papi-util.ts +++ b/src/shared/utils/papi-util.ts @@ -74,6 +74,8 @@ export const createSafeRegisterFn = >( // #endregion +// #region Request/Response types + /** * Type of object passed to a complex request handler that provides information about the request. * This type is used as the public-facing interface for requests @@ -121,6 +123,10 @@ export enum RequestHandlerType { Complex = 'complex', } +// #endregion + +// #region Equality checking functions + /** * Check that two objects are deeply equal, comparing members of each object and such * @@ -148,14 +154,91 @@ export function deepEqual(a: unknown, b: unknown) { return isEqualDeep(a, b); } +// #endregion + +// #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. + * + * 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. + * + * @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 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. + */ +export function serialize( + value: unknown, + replacer?: (this: unknown, key: string, value: unknown) => unknown, + space?: string | number, +): string { + const undefinedReplacer = (replacerKey: string, replacerValue: unknown) => { + let newValue = replacerValue; + if (replacer) newValue = replacer(replacerKey, newValue); + // All `undefined` values become `null` on the way from JS objects into JSON strings + // eslint-disable-next-line no-null/no-null + if (newValue === undefined) newValue = null; + return newValue; + }; + return JSON.stringify(value, undefinedReplacer, space); +} + /** - * Check to see if the value is `JSON.stringify` serializable without losing information + * Converts a JSON string into a value. + * + * 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. + * + * @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. + */ +export function deserialize( + value: string, + reviver?: (this: unknown, key: string, value: unknown) => unknown, + // 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); +} + +/** + * Check to see if the value is serializable without losing information * * @param value Value to test * @returns True if serializable; false otherwise * - * Note: the value `undefined` is not serializable as `JSON.parse` throws on it. `null` is - * serializable. However, `undefined` or `null` on properties of objects is serializable. + * 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. * * WARNING: This is inefficient right now as it stringifies, parses, stringifies, and === the value. * Please only use this if you need to @@ -171,7 +254,8 @@ export function deepEqual(a: unknown, b: unknown) { */ export function isSerializable(value: unknown): boolean { try { - return JSON.stringify(value) === JSON.stringify(JSON.parse(JSON.stringify(value))); + const serializedValue = serialize(value); + return serializedValue === serialize(deserialize(serializedValue)); } catch (e) { return false; } @@ -238,6 +322,10 @@ export const htmlEncode = (str: string): string => .replace(/'/g, ''') .replace(/\//g, '/'); +// #endregion + +// #region Module loading + /** * Modules that someone might try to require in their extensions that we have similar apis for. When * an extension requires these modules, an error throws that lets them know about our similar api. @@ -273,6 +361,8 @@ export function getModuleSimilarApiMessage(moduleName: string) { } bundling the module into your code with a build tool like webpack`; } +// #endregion + /** * JSDOC SOURCE papiUtil * diff --git a/src/shared/utils/util.ts b/src/shared/utils/util.ts index cb52789b89..9d2f8a977e 100644 --- a/src/shared/utils/util.ts +++ b/src/shared/utils/util.ts @@ -97,6 +97,8 @@ type ErrorWithMessage = { function isErrorWithMessage(error: unknown): error is ErrorWithMessage { return ( typeof error === 'object' && + // We're potentially dealing with objects we didn't create, so they might contain `null` + // eslint-disable-next-line no-null/no-null error !== null && 'message' in error && // Type assert `error` to check it's `message`. @@ -150,11 +152,11 @@ export function wait(ms: number) { * * @param fn The function to run * @param maxWaitTimeInMS The maximum amount of time to wait for the function to resolve - * @returns Promise that resolves to the resolved value of the function or null if it ran longer - * than the specified wait time + * @returns Promise that resolves to the resolved value of the function or undefined if it ran + * longer than the specified wait time */ export function waitForDuration(fn: () => Promise, maxWaitTimeInMS: number) { - const timeout = wait(maxWaitTimeInMS).then(() => null); + const timeout = wait(maxWaitTimeInMS).then(() => undefined); return Promise.any([timeout, fn()]); }