Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(suite): use structuredClone for deep copying #6071

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/connect-explorer/src/components/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { withRouter } from 'react-router-dom';
import InfinityMenu from 'react-infinity-menu';
import { createGlobalStyle } from 'styled-components';
import { deepClone } from '@trezor/utils';

import config from '../data/menu';

Expand Down Expand Up @@ -139,7 +140,7 @@ const findFiltered = (url: any, tree: any, node: any, key: any) => {

const getTree = (url: string) => {
// clone config
const tree = JSON.parse(JSON.stringify(config));
const tree = deepClone(config);
const filteredTree = tree.reduce((prev, curr, key) => {
if (key === undefined) return prev;
return findFiltered(url, prev, curr, key);
Expand Down
16 changes: 7 additions & 9 deletions packages/connect-explorer/src/reducers/methodReducer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable no-eval */

import stringifyObject from 'stringify-object';
import { deepClone } from '@trezor/utils';

import {
TAB_CHANGE,
Expand Down Expand Up @@ -182,7 +183,7 @@ const findField = (state: MethodState, field: any) => {

const onFieldChange = (state: MethodState, _field: any, value: any) => {
const newState = {
...JSON.parse(JSON.stringify(state)),
...deepClone(state),
...state,
};
const field = findField(newState, _field);
Expand All @@ -206,10 +207,7 @@ const getMethodState = (url: string) => {
const method = config.find(m => m.url === url);
if (!method) return initialState;
// clone object
const state = {
...JSON.parse(JSON.stringify(method)),
// ...method,
};
const state = deepClone(method) as MethodState;

// set default values
state.fields = state.fields.map(f => setAffectedValues(state, prepareBundle(f)));
Expand All @@ -219,9 +217,9 @@ const getMethodState = (url: string) => {
};

const onAddBatch = (state: MethodState, _field: Field<any>, item: any) => {
const newState = JSON.parse(JSON.stringify(state));
const newState = deepClone(state) as any;
const field = newState.fields.find(f => f.name === _field.name);
field.items = [...field.items, item];
field.items = [...field?.items, item];
prepareBundle(field);

return updateParams(newState);
Expand All @@ -231,8 +229,8 @@ const onRemoveBatch = (state: MethodState, _field: any, _batch: any) => {
const field = state.fields.find(f => f.name === _field.name);
const items = field?.items?.filter(batch => batch !== _batch);

const newState = JSON.parse(JSON.stringify(state));
const newField = newState.fields.find(f => f.name === field?.name);
const newState = deepClone(state);
const newField = newState.fields.find(f => f.name === field?.name) as any;
newField.items = items;
prepareBundle(newField);

Expand Down
3 changes: 2 additions & 1 deletion packages/connect/src/api/common/paramsValidator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// origin: https:/trezor/connect/blob/develop/src/js/core/methods/helpers/paramsValidator.js
import { deepClone } from '@trezor/utils';

import { ERRORS } from '../../constants';
import { fromHardened } from '../../utils/pathUtils';
Expand Down Expand Up @@ -102,7 +103,7 @@ export const getFirmwareRange = (
coinInfo: CoinInfo | null | undefined,
currentRange: FirmwareRange,
) => {
const current: FirmwareRange = JSON.parse(JSON.stringify(currentRange));
const current: FirmwareRange = deepClone(currentRange);
// set minimum required firmware from coins.json (coinInfo)
if (coinInfo) {
if (!coinInfo.support || typeof coinInfo.support.trezor1 !== 'string') {
Expand Down
3 changes: 2 additions & 1 deletion packages/connect/src/api/customMessage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// origin: https:/trezor/connect/blob/develop/src/js/core/methods/CustomMessage.js

import { deepClone } from '@trezor/utils';
import { AbstractMethod } from '../core/AbstractMethod';
import { validateParams } from './common/paramsValidator';
import { ERRORS } from '../constants';
Expand All @@ -26,7 +27,7 @@ export default class CustomMessage extends AbstractMethod<'customMessage', Param

if (payload.messages) {
try {
JSON.parse(JSON.stringify(payload.messages));
deepClone(payload.messages);
} catch (error) {
throw ERRORS.TypedError(
'Method_InvalidParameter',
Expand Down
22 changes: 6 additions & 16 deletions packages/connect/src/data/coinInfo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// origin: https:/trezor/connect/blob/develop/src/js/data/CoinInfo.js

import { deepClone } from '@trezor/utils';
import { ERRORS } from '../constants';
import { toHardened, fromHardened } from '../utils/pathUtils';
import type {
Expand All @@ -13,19 +14,8 @@ const bitcoinNetworks: BitcoinNetworkInfo[] = [];
const ethereumNetworks: EthereumNetworkInfo[] = [];
const miscNetworks: MiscNetworkInfo[] = [];

// TODO: replace by structuredClone() after updating TS
export function cloneCoinInfo<T>(info: T): T {
const jsonString = JSON.stringify(info);
if (jsonString === undefined) {
// jsonString === undefined IF and only IF obj === undefined
// therefore no need to clone
return info;
}
return JSON.parse(jsonString);
}

export const getBitcoinNetwork = (pathOrName: number[] | string) => {
const networks = cloneCoinInfo(bitcoinNetworks);
const networks: BitcoinNetworkInfo[] = deepClone(bitcoinNetworks);
if (typeof pathOrName === 'string') {
const name = pathOrName.toLowerCase();
return networks.find(
Expand All @@ -40,7 +30,7 @@ export const getBitcoinNetwork = (pathOrName: number[] | string) => {
};

export const getEthereumNetwork = (pathOrName: number[] | string) => {
const networks = cloneCoinInfo(ethereumNetworks);
const networks: EthereumNetworkInfo[] = deepClone(ethereumNetworks);
if (typeof pathOrName === 'string') {
const name = pathOrName.toLowerCase();
return networks.find(
Expand All @@ -52,7 +42,7 @@ export const getEthereumNetwork = (pathOrName: number[] | string) => {
};

export const getMiscNetwork = (pathOrName: number[] | string) => {
const networks = cloneCoinInfo(miscNetworks);
const networks: MiscNetworkInfo[] = deepClone(miscNetworks);
if (typeof pathOrName === 'string') {
const name = pathOrName.toLowerCase();
return networks.find(
Expand Down Expand Up @@ -95,7 +85,7 @@ export const getBech32Network = (coin: BitcoinNetworkInfo) => {

// fix coinInfo network values from path (segwit/legacy)
export const fixCoinInfoNetwork = (ci: BitcoinNetworkInfo, path: number[]) => {
const coinInfo = cloneCoinInfo(ci);
const coinInfo: BitcoinNetworkInfo = deepClone(ci);
if (path[0] === toHardened(84)) {
const bech32Network = getBech32Network(coinInfo);
if (bech32Network) {
Expand Down Expand Up @@ -131,7 +121,7 @@ const detectBtcVersion = (data: { subversion?: string }) => {

// TODO: https:/trezor/trezor-suite/issues/4886
export const getCoinInfoByHash = (hash: string, networkInfo: any) => {
const networks = cloneCoinInfo(bitcoinNetworks);
const networks: BitcoinNetworkInfo[] = deepClone(bitcoinNetworks);
const result = networks.find(
info => hash.toLowerCase() === info.hashGenesisBlock.toLowerCase(),
);
Expand Down
4 changes: 2 additions & 2 deletions packages/suite/src/actions/suite/metadataActions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import TrezorConnect from '@trezor/connect';
import { analytics, EventType } from '@trezor/suite-analytics';

import { createDeferred } from '@trezor/utils';
import { createDeferred, deepClone } from '@trezor/utils';
import { METADATA } from '@suite-actions/constants';
import { Dispatch, GetState } from '@suite-types';
import {
Expand Down Expand Up @@ -518,7 +518,7 @@ export const addAccountMetadata =
const account = getState().wallet.accounts.find(a => a.key === payload.accountKey);
if (!account) return false;
// clone Account.metadata
const metadata = JSON.parse(JSON.stringify(account.metadata));
const metadata = deepClone(account.metadata);

if (payload.type === 'outputLabel') {
if (typeof payload.value !== 'string' || payload.value.length === 0) {
Expand Down
3 changes: 2 additions & 1 deletion packages/suite/src/actions/wallet/formDraftActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getFormDraftKey } from '@suite-common/wallet-utils';
import { FORM_DRAFT } from './constants';

import type { FormDraftKeyPrefix, FormDraft } from '@wallet-types/form';
import { deepClone } from '@trezor/utils';

export type FormDraftAction =
| {
Expand Down Expand Up @@ -36,7 +37,7 @@ export const getDraft =

if (draft) {
// draft is a read-only redux object. make a copy to be able to modify values
return JSON.parse(JSON.stringify(draft)) as T;
return deepClone(draft) as T;
}
};

Expand Down
19 changes: 4 additions & 15 deletions packages/suite/src/actions/wallet/sendFormActions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import TrezorConnect, { PROTO } from '@trezor/connect';
import BigNumber from 'bignumber.js';
import { deepClone } from '@trezor/utils';

import * as accountActions from '@wallet-actions/accountActions';
import * as blockchainActions from '@wallet-actions/blockchainActions';
import * as transactionActions from '@wallet-actions/transactionActions';
Expand Down Expand Up @@ -82,7 +84,7 @@ export const getDraft = () => (_dispatch: Dispatch, getState: GetState) => {
const draft = send.drafts[selectedAccount.account.key];
if (draft) {
// draft is a read-only redux object. make a copy to be able to modify values
return JSON.parse(JSON.stringify(draft));
return deepClone(draft);
}
};

Expand All @@ -99,19 +101,6 @@ export const removeDraft = () => (dispatch: Dispatch, getState: GetState) => {
}
};

// TODO: replace by structuredClone() after updating TS
const clone = <T>(info: T): T => {
const jsonString = JSON.stringify(info);

if (jsonString === undefined) {
// jsonString === undefined IF and only IF obj === undefined
// therefore no need to clone
return info;
}

return JSON.parse(jsonString);
};

export const convertDrafts = () => (dispatch: Dispatch, getState: GetState) => {
const { route } = getState().router;

Expand Down Expand Up @@ -146,7 +135,7 @@ export const convertDrafts = () => (dispatch: Dispatch, getState: GetState) => {
const conversionToUse =
areSatsSelected && areSatsSupported ? amountToSatoshi : formatAmount;

const updatedDraft = clone(draft);
const updatedDraft: FormState = deepClone(draft);
const decimals = getAccountDecimals(relatedAccount.symbol)!;

updatedDraft.outputs.forEach(output => {
Expand Down
15 changes: 15 additions & 0 deletions packages/utils/src/deepClone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const deepClone = <T>(value: T): T => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are ~ 12 more places where JSON.parse(JSON.stringify... is used, I guess it would be nice to use this util on all those places?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest little bit restrict values that can be passed, because you can pass anything now I think, but I am not sure how JSON will behave if you will pass object with BigInt or other special types. It will work for structuredClone but JSON could mess it up, so we should limit it only types that will work 100% for both solutions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And now we are back to square 1 from which the conversation has started hehe :D Yeah, makes sense.

Copy link
Member

@karliatto karliatto Aug 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that we should consider that objects are going to be stringified when using JSON.pasrse(JSON.stringify....
I am not worried about current places where it is used (because it is already working) but in potential new uses where we think that this behaves like structuredClone and in in some scenarios it will not.

const appointment = {
    location: 'Prague',
    day: new Date(),
}

const whenStructuredClone = structuredClone(appointment)
whenStructuredClone.day.getDate()
// result -> 25

const whenStringified = JSON.parse(JSON.stringify(appointment))
whenStringified.day.getDate()
// result -> Uncaught TypeError: whenStringified.day.getDate is not a function

And the same for nested objects. And the same behavior can be expected from other objects like BigInt.

Restricting the use to types that will work 100% for both could be a solution but if I am not mistaken I think it should iterate over the whole object and nested potential objects to make sure there is no nested BigInt or other not working type.

Copy link
Contributor

@Nodonisko Nodonisko Aug 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think type like this should do the job:

import { JsonValue } from 'type-fest'

if (value === undefined) {
return value;
}

let clone;

if (typeof structuredClone === 'function') {
clone = structuredClone(value);
} else {
clone = JSON.parse(JSON.stringify(value));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably use some better polyfill than this. There are already available.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never heard of it before, core-js has a polyfill. (I know we are cautious about included new dependencies, but just sharing).

https:/zloirock/core-js#structuredclone

}

return clone;
};
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export * from './throwError';
export * from './isHex';
export * from './resolveStaticPath';
export * from './createTimeoutPromise';
export * from './deepClone';
24 changes: 24 additions & 0 deletions packages/utils/tests/deepClone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { deepClone } from '../src/deepClone';

describe('deepClone', () => {
it('deepClone works', () => {
const original = {
a: 1,
b: '2',
c: {
a: 1,
b: '2',
c: {
a: [1, 2, 3],
b: '2',
},
},
};

const copy = deepClone(original);
const shallowCopy = { ...original };

expect(copy.c.c.a === original.c.c.a).toBeFalsy();
expect(shallowCopy.c.c.a === original.c.c.a).toBeTruthy();
});
});
3 changes: 2 additions & 1 deletion packages/utxo-lib/tests/compose.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { deepClone } from '@trezor/utils';
import { composeTx } from '../src';
import * as utils from '../src/compose/utils';
import { Permutation } from '../src/compose/permutation';
Expand Down Expand Up @@ -26,7 +27,7 @@ describe('composeTx', () => {
delete input.REV_hash;
});
const o = result.transaction.PERM_outputs;
const sorted = JSON.parse(JSON.stringify(o.sorted));
const sorted = deepClone(o.sorted);
sorted.forEach((ss: any) => {
const s = ss;
if (s.opReturnData != null) {
Expand Down