Major sol-profiler overhaul

This commit is contained in:
Leonid Logvinov 2019-02-22 16:16:03 -08:00
parent 047de370d6
commit 04d8f46ff3
No known key found for this signature in database
GPG Key ID: 0DD294BFDE8C95D4
13 changed files with 320 additions and 61 deletions

View File

@ -11,8 +11,8 @@ import {
StatementCoverage,
StatementDescription,
Subtrace,
SubTraceInfo,
TraceCollector,
TraceInfo,
TraceInfoSubprovider,
utils,
} from '@0x/sol-tracing-utils';
@ -39,8 +39,8 @@ export class CoverageSubprovider extends TraceInfoSubprovider {
super(defaultFromAddress, traceCollectionSubproviderConfig);
this._coverageCollector = new TraceCollector(artifactAdapter, isVerbose, coverageHandler);
}
protected async _handleTraceInfoAsync(traceInfo: TraceInfo): Promise<void> {
await this._coverageCollector.computeSingleTraceCoverageAsync(traceInfo);
protected async _handleSubTraceInfoAsync(subTraceInfo: SubTraceInfo): Promise<void> {
await this._coverageCollector.computeSingleTraceCoverageAsync(subTraceInfo);
}
/**
* Write the test coverage results to a file in Istanbul format.

View File

@ -1,4 +1,25 @@
[
{
"version": "3.0.0",
"changes": [
{
"note": "Big Sol-profiler overhaul. It now has a bunch of new features",
"pr": "TODO"
},
{
"note": "Added a CLI interface for reporting non line-based profiling info",
"pr": "TODO"
},
{
"note": "Add memory consumption analysis",
"pr": "TODO"
},
{
"note": "Add calldata analysis",
"pr": "TODO"
}
]
},
{
"timestamp": 1549733923,
"version": "2.0.4",
@ -81,4 +102,4 @@
}
]
}
]
]

View File

@ -33,6 +33,8 @@
"@0x/subproviders": "^3.0.3",
"@0x/typescript-typings": "^4.0.0",
"ethereum-types": "^2.0.0",
"ethereumjs-util": "^5.1.1",
"@0x/utils": "^4.1.0",
"lodash": "^4.17.11",
"web3-provider-engine": "14.0.6"
},
@ -49,4 +51,4 @@
"publishConfig": {
"access": "public"
}
}
}

View File

@ -0,0 +1,94 @@
import { TraceInfo } from '@0x/sol-tracing-utils';
import { logUtils } from '@0x/utils';
import { OpCode } from 'ethereum-types';
import { stripHexPrefix } from 'ethereumjs-util';
import * as _ from 'lodash';
const ZERO_BYTE_CALL_DATA_COST = 4;
const NON_ZERO_BYTE_CALL_DATA_COST = 68;
const WORD_SIZE = 32;
const G_MEMORY = 3;
const G_QUAD_COEF = 512;
const HEX_BASE = 16;
const G_COPY = 3;
export const costUtils = {
reportCallDataCost(traceInfo: TraceInfo): number {
if (_.isUndefined(traceInfo.data)) {
// No call data to report
return 0;
}
const callData = traceInfo.data;
const callDataBuf = Buffer.from(stripHexPrefix(callData), 'hex');
const { true: zeroBytesCountIfExist, false: nonZeroBytesCountIfExist } = _.countBy(
callDataBuf,
byte => byte === 0,
);
const zeroBytesCost = (zeroBytesCountIfExist || 0) * ZERO_BYTE_CALL_DATA_COST;
const nonZeroBytesCost = (nonZeroBytesCountIfExist || 0) * NON_ZERO_BYTE_CALL_DATA_COST;
const callDataCost = zeroBytesCost + nonZeroBytesCost;
logUtils.logHeader('Call data breakdown', '-');
logUtils.table({
'call data size (bytes)': callData.length,
callDataCost,
zeroBytesCost,
nonZeroBytesCost,
zeroBytesCountIfExist,
nonZeroBytesCountIfExist,
});
return callDataCost;
},
reportMemoryCost(traceInfo: TraceInfo): number {
const structLogs = traceInfo.trace.structLogs;
const MEMORY_OPCODES = [OpCode.MLoad, OpCode.MStore, OpCode.MStore8];
const CALL_DATA_OPCODES = [OpCode.CallDataCopy];
const memoryLogs = _.filter(structLogs, structLog =>
_.includes([...MEMORY_OPCODES, ...CALL_DATA_OPCODES], structLog.op),
);
const memoryLocationsAccessed = _.map(memoryLogs, structLog => {
if (_.includes(CALL_DATA_OPCODES, structLog.op)) {
const memOffset = parseInt(structLog.stack[0], HEX_BASE);
const length = parseInt(structLog.stack[2], HEX_BASE);
return memOffset + length;
} else {
return parseInt(structLog.stack[0], HEX_BASE);
}
});
const highestMemoryLocationAccessed = _.max(memoryLocationsAccessed);
return costUtils._printMemoryCost(highestMemoryLocationAccessed);
},
reportCopyingCost(traceInfo: TraceInfo): number {
const structLogs = traceInfo.trace.structLogs;
const COPY_OPCODES = [OpCode.CallDataCopy];
const copyLogs = _.filter(structLogs, structLog => _.includes(COPY_OPCODES, structLog.op));
const copyCosts = _.map(copyLogs, structLog => {
const length = parseInt(structLog.stack[2], HEX_BASE);
return Math.ceil(length / WORD_SIZE) * G_COPY;
});
return _.sum(copyCosts);
},
reportOpcodesCost(traceInfo: TraceInfo): number {
const structLogs = traceInfo.trace.structLogs;
const gasCosts = _.map(structLogs, structLog => structLog.gasCost);
const gasCost = _.sum(gasCosts);
return gasCost;
},
_printMemoryCost(highestMemoryLocationAccessed?: number): number {
if (_.isUndefined(highestMemoryLocationAccessed)) {
return 0;
}
const memoryWordsUsed = Math.ceil((highestMemoryLocationAccessed + WORD_SIZE) / WORD_SIZE);
const linearMemoryCost = G_MEMORY * memoryWordsUsed;
const quadraticMemoryCost = Math.floor((memoryWordsUsed * memoryWordsUsed) / G_QUAD_COEF);
const memoryCost = linearMemoryCost + quadraticMemoryCost;
logUtils.logHeader('Memory breakdown', '-');
logUtils.table({
'memoryCost = linearMemoryCost + quadraticMemoryCost': memoryCost,
linearMemoryCost,
quadraticMemoryCost,
highestMemoryLocationAccessed,
memoryWordsUsed,
});
return memoryCost;
},
};

View File

@ -1,5 +1,3 @@
import * as _ from 'lodash';
import {
AbstractArtifactAdapter,
collectCoverageEntries,
@ -8,11 +6,20 @@ import {
SingleFileSubtraceHandler,
SourceRange,
Subtrace,
SubTraceInfo,
TraceCollector,
TraceInfo,
TraceInfoSubprovider,
utils,
} from '@0x/sol-tracing-utils';
import { logUtils } from '@0x/utils';
import { stripHexPrefix } from 'ethereumjs-util';
import * as _ from 'lodash';
import { costUtils } from './cost_utils';
const CREATE_COST = 32000;
const DEPLOYED_BYTE_COST = 200;
/**
* This class implements the [web3-provider-engine](https://github.com/MetaMask/provider-engine) subprovider interface.
@ -35,8 +42,64 @@ export class ProfilerSubprovider extends TraceInfoSubprovider {
super(defaultFromAddress, traceCollectionSubproviderConfig);
this._profilerCollector = new TraceCollector(artifactAdapter, isVerbose, profilerHandler);
}
protected async _handleSubTraceInfoAsync(subTraceInfo: SubTraceInfo): Promise<void> {
await this._profilerCollector.computeSingleTraceCoverageAsync(subTraceInfo);
}
// tslint:disable prefer-function-over-method
protected async _handleTraceInfoAsync(traceInfo: TraceInfo): Promise<void> {
await this._profilerCollector.computeSingleTraceCoverageAsync(traceInfo);
const receipt = await this._web3Wrapper.getTransactionReceiptIfExistsAsync(
traceInfo.txHash,
);
if (_.isUndefined(receipt)) {
return;
}
const BASE_COST = 21000;
if (receipt.gasUsed === BASE_COST) {
// Value transfer
return;
}
logUtils.header(`Profiling data for ${traceInfo.txHash}`);
traceInfo.trace.structLogs = utils.normalizeStructLogs(traceInfo.trace.structLogs);
const callDataCost = costUtils.reportCallDataCost(traceInfo);
const memoryCost = costUtils.reportMemoryCost(traceInfo);
const opcodesCost = costUtils.reportOpcodesCost(traceInfo);
const dataCopyingCost = costUtils.reportCopyingCost(traceInfo);
const newContractCost = CREATE_COST;
const transactionBaseCost = BASE_COST;
let totalCost = callDataCost + opcodesCost + BASE_COST;
logUtils.header('Final breakdown', '-');
if (!_.isNull(receipt.contractAddress)) {
const code = await this._web3Wrapper.getContractCodeAsync(receipt.contractAddress);
const codeBuff = Buffer.from(stripHexPrefix(code), 'hex');
const codeLength = codeBuff.length;
const contractSizeCost = codeLength * DEPLOYED_BYTE_COST;
totalCost += contractSizeCost + CREATE_COST;
logUtils.table({
'totalCost = callDataCost + opcodesCost + transactionBaseCost + newContractCost + contractSizeCost': totalCost,
callDataCost,
'opcodesCost (including memoryCost and dataCopyingCost)': opcodesCost,
memoryCost,
dataCopyingCost,
transactionBaseCost,
contractSizeCost,
newContractCost,
});
} else {
logUtils.table({
'totalCost = callDataCost + opcodesCost + transactionBaseCost': totalCost,
callDataCost,
'opcodesCost (including memoryCost and dataCopyingCost)': opcodesCost,
memoryCost,
dataCopyingCost,
transactionBaseCost,
});
}
const unknownGas = receipt.gasUsed - totalCost;
if (unknownGas !== 0) {
logUtils.warn(
`Unable to find the cause for ${unknownGas} gas. It's most probably an issue in sol-profiler. Please report on Github.`,
);
}
}
/**
* Write the test profiler results to a file in Istanbul format.

View File

@ -87,4 +87,4 @@
"publishConfig": {
"access": "public"
}
}
}

View File

@ -59,6 +59,8 @@ export class SolCompilerArtifactAdapter extends AbstractArtifactAdapter {
sourceCodes[value.id] = source.source;
});
const contractData = {
abi: artifact.compilerOutput.abi,
name: artifact.contractName,
sourceCodes,
sources,
bytecode: artifact.compilerOutput.evm.bytecode.object,

View File

@ -12,7 +12,7 @@ export {
BranchCoverage,
BranchDescription,
Subtrace,
TraceInfo,
SubTraceInfo,
Coverage,
LineColumn,
LineCoverage,
@ -24,9 +24,10 @@ export {
FnMap,
OffsetToLocation,
StatementMap,
TraceInfoBase,
TraceInfoExistingContract,
TraceInfoNewContract,
TraceInfo,
SubTraceInfoBase,
SubTraceInfoExistingContract,
SubTraceInfoNewContract,
Sources,
SourceCodes,
} from './types';

View File

@ -15,9 +15,9 @@ import {
Coverage,
SourceRange,
Subtrace,
TraceInfo,
TraceInfoExistingContract,
TraceInfoNewContract,
SubTraceInfo,
SubTraceInfoExistingContract,
SubTraceInfoNewContract,
} from './types';
import { utils } from './utils';
@ -62,32 +62,43 @@ export class TraceCollector {
await mkdirpAsync('coverage');
fs.writeFileSync('coverage/coverage.json', stringifiedCoverage);
}
public async computeSingleTraceCoverageAsync(traceInfo: TraceInfo): Promise<void> {
public async getContractDataByTraceInfoIfExistsAsync(
address: string,
bytecode: string,
isContractCreation: boolean,
): Promise<ContractData | undefined> {
if (_.isUndefined(this._contractsData)) {
this._contractsData = await this._artifactAdapter.collectContractsDataAsync();
}
const isContractCreation = traceInfo.address === constants.NEW_CONTRACT;
const bytecode = isContractCreation
? (traceInfo as TraceInfoNewContract).bytecode
: (traceInfo as TraceInfoExistingContract).runtimeBytecode;
const contractData = utils.getContractDataIfExists(this._contractsData, bytecode);
if (_.isUndefined(contractData)) {
const shortenHex = (hex: string) => {
/**
* Length chooses so that both error messages are of the same length
* and it's enough data to figure out which artifact has a problem.
*/
const length = 18;
return `${hex.substr(0, length + 2)}...${hex.substr(hex.length - length, length)}`;
};
/**
* Length chooses so that both error messages are of the same length
* and it's enough data to figure out which artifact has a problem.
*/
const HEX_LENGTH = 16;
const errMsg = isContractCreation
? `Unable to find matching bytecode for contract creation ${chalk.bold(
shortenHex(bytecode),
utils.shortenHex(bytecode, HEX_LENGTH),
)}, please check your artifacts. Ignoring...`
: `Unable to find matching bytecode for contract address ${chalk.bold(
traceInfo.address,
address,
)}, please check your artifacts. Ignoring...`;
this._logger.warn(errMsg);
}
return contractData;
}
public async computeSingleTraceCoverageAsync(subTraceInfo: SubTraceInfo): Promise<void> {
const isContractCreation = subTraceInfo.address === constants.NEW_CONTRACT;
const bytecode = isContractCreation
? (subTraceInfo as SubTraceInfoNewContract).bytecode
: (subTraceInfo as SubTraceInfoExistingContract).runtimeBytecode;
const contractData = await this.getContractDataByTraceInfoIfExistsAsync(
subTraceInfo.address,
bytecode,
isContractCreation,
);
if (_.isUndefined(contractData)) {
return;
}
const bytecodeHex = stripHexPrefix(bytecode);
@ -96,7 +107,7 @@ export class TraceCollector {
_.map(contractData.sources, (_sourcePath: string, fileIndex: string) => {
const singleFileCoverageForTrace = this._singleFileSubtraceHandler(
contractData,
traceInfo.subtrace,
subTraceInfo.subtrace,
pcToSourceRange,
_.parseInt(fileIndex),
);

View File

@ -1,16 +1,21 @@
import { NodeType } from '@0x/web3-wrapper';
import * as fs from 'fs';
import * as _ from 'lodash';
import { constants } from './constants';
import { getContractAddressToTraces } from './trace';
import { TraceCollectionSubprovider } from './trace_collection_subprovider';
import { TraceInfo, TraceInfoExistingContract, TraceInfoNewContract } from './types';
import { SubTraceInfo, SubTraceInfoExistingContract, SubTraceInfoNewContract, TraceInfo } from './types';
// TraceInfoSubprovider is extended by subproviders which need to work with one
// TraceInfo at a time. It has one abstract method: _handleTraceInfoAsync, which
// is called for each TraceInfo.
export abstract class TraceInfoSubprovider extends TraceCollectionSubprovider {
protected abstract _handleTraceInfoAsync(traceInfo: TraceInfo): Promise<void>;
protected abstract _handleSubTraceInfoAsync(subTraceInfo: SubTraceInfo): Promise<void>;
// tslint:disable prefer-function-over-method
protected _handleTraceInfoAsync(_traceInfo: TraceInfo): Promise<void> {
return Promise.resolve(undefined);
}
protected async _recordTxTraceAsync(address: string, data: string | undefined, txHash: string): Promise<void> {
await this._web3Wrapper.awaitTransactionMinedAsync(txHash, 0);
const nodeType = await this._web3Wrapper.getNodeTypeAsync();
@ -24,6 +29,13 @@ export abstract class TraceInfoSubprovider extends TraceCollectionSubprovider {
const tracer = `
{
data: [],
extractStack: function (stack) {
var extract = [];
for (var i = 0; i < stack.length(); i++) {
extract.push('0x' + stack.peek(i).toString(16));
}
return extract;
},
step: function(log) {
const op = log.op.toString();
const opn = 0 | log.op.toNumber();
@ -32,7 +44,16 @@ export abstract class TraceInfoSubprovider extends TraceCollectionSubprovider {
const gasCost = 0 | log.getCost();
const gas = 0 | log.getGas();
const isCall = opn == 0xf1 || opn == 0xf2 || opn == 0xf4 || opn == 0xf5 || opn == 0xfa;
const stack = isCall ? ['0x'+log.stack.peek(1).toString(16), null] : null;
const isMemoryAccess = opn == 0x51 || opn == 0x52 || opn == 0x53;
const isCallDataAccess = opn == 0x37;
var stack;
if (isCall) {
stack = ['0x'+log.stack.peek(1).toString(16), null];
} else if (isMemoryAccess) {
stack = ['0x'+log.stack.peek(0).toString(16)];
} else if (isCallDataAccess) {
stack = ['0x'+log.stack.peek(2).toString(16), '0x'+log.stack.peek(1).toString(16), '0x'+log.stack.peek(0).toString(16)];
}
this.data.push({ pc, gasCost, depth, op, stack, gas });
},
fault: function() { },
@ -50,14 +71,23 @@ export abstract class TraceInfoSubprovider extends TraceCollectionSubprovider {
disableStorage: true,
});
}
const traceInfo = {
trace,
address,
data,
txHash,
};
await this._handleTraceInfoAsync(traceInfo);
const contractAddressToTraces = getContractAddressToTraces(trace.structLogs, address);
const subcallAddresses = _.keys(contractAddressToTraces);
if (address === constants.NEW_CONTRACT) {
for (const subcallAddress of subcallAddresses) {
let traceInfo: TraceInfoNewContract | TraceInfoExistingContract;
let subTraceInfo: SubTraceInfoNewContract | SubTraceInfoExistingContract;
const traceForThatSubcall = contractAddressToTraces[subcallAddress];
const subcallDepth = traceForThatSubcall[0].depth;
if (subcallAddress === 'NEW_CONTRACT') {
const traceForThatSubcall = contractAddressToTraces[subcallAddress];
traceInfo = {
subTraceInfo = {
subcallDepth,
subtrace: traceForThatSubcall,
txHash,
address: subcallAddress,
@ -65,27 +95,29 @@ export abstract class TraceInfoSubprovider extends TraceCollectionSubprovider {
};
} else {
const runtimeBytecode = await this._web3Wrapper.getContractCodeAsync(subcallAddress);
const traceForThatSubcall = contractAddressToTraces[subcallAddress];
traceInfo = {
subTraceInfo = {
subcallDepth,
subtrace: traceForThatSubcall,
txHash,
address: subcallAddress,
runtimeBytecode,
};
}
await this._handleTraceInfoAsync(traceInfo);
await this._handleSubTraceInfoAsync(subTraceInfo);
}
} else {
for (const subcallAddress of subcallAddresses) {
const runtimeBytecode = await this._web3Wrapper.getContractCodeAsync(subcallAddress);
const traceForThatSubcall = contractAddressToTraces[subcallAddress];
const traceInfo: TraceInfoExistingContract = {
const subcallDepth = traceForThatSubcall[0].depth;
const subTraceInfo: SubTraceInfoExistingContract = {
subcallDepth,
subtrace: traceForThatSubcall,
txHash,
address: subcallAddress,
runtimeBytecode,
};
await this._handleTraceInfoAsync(traceInfo);
await this._handleSubTraceInfoAsync(subTraceInfo);
}
}
}

View File

@ -1,4 +1,4 @@
import { StructLog } from 'ethereum-types';
import { ContractAbi, StructLog, TransactionTrace } from 'ethereum-types';
export interface LineColumn {
line: number;
@ -83,6 +83,8 @@ export interface Sources {
}
export interface ContractData {
abi: ContractAbi;
name: string;
bytecode: string;
sourceMap: string;
runtimeBytecode: string;
@ -94,22 +96,30 @@ export interface ContractData {
// Part of the trace executed within the same context
export type Subtrace = StructLog[];
export interface TraceInfoBase {
export interface SubTraceInfoBase {
subtrace: Subtrace;
txHash: string;
subcallDepth: number;
}
export interface TraceInfoNewContract extends TraceInfoBase {
export interface SubTraceInfoNewContract extends SubTraceInfoBase {
address: 'NEW_CONTRACT';
bytecode: string;
}
export interface TraceInfoExistingContract extends TraceInfoBase {
export interface SubTraceInfoExistingContract extends SubTraceInfoBase {
address: string;
runtimeBytecode: string;
}
export type TraceInfo = TraceInfoNewContract | TraceInfoExistingContract;
export type SubTraceInfo = SubTraceInfoNewContract | SubTraceInfoExistingContract;
export interface TraceInfo {
trace: TransactionTrace;
txHash: string;
address: string;
data: string | undefined;
}
export enum BlockParamLiteral {
Latest = 'latest',

View File

@ -81,17 +81,44 @@ export const utils = {
return addressUtils.padZeros(new BigNumber(addHexPrefix(stackEntry)).toString(hexBase));
},
normalizeStructLogs(structLogs: StructLog[]): StructLog[] {
if (_.isEmpty(structLogs)) {
return structLogs;
}
if (structLogs[0].depth === 1) {
// Geth uses 1-indexed depth counter whilst ganache starts from 0
const newStructLogs = _.map(structLogs, structLog => {
const newStructLogs = _.map(structLogs, (structLog: StructLog, idx: number) => {
const newStructLog = {
...structLog,
depth: structLog.depth - 1,
};
if (newStructLog.op === 'STATICCALL') {
if (newStructLog.op === OpCode.StaticCall) {
// HACK(leo): Geth traces sometimes returns those gas costs incorrectly as very big numbers so we manually fix them.
newStructLog.gasCost = STATICCALL_GAS_COST;
}
// if (newStructLog.op === OpCode.MStore) {
// // HACK(leo): Geth traces sometimes returns those gas costs incorrectly as very big numbers so we manually fix them.
// newStructLog.gasCost = 3;
// }
// if (newStructLog.op === OpCode.MLoad) {
// // HACK(leo): Geth traces sometimes returns those gas costs incorrectly as very big numbers so we manually fix them.
// newStructLog.gasCost = 3;
// }
// if (newStructLog.op === OpCode.CallDataCopy) {
// // HACK(leo): Geth traces sometimes returns those gas costs incorrectly as very big numbers so we manually fix them.
// newStructLog.gasCost = 3;
// }
if (newStructLog.op === 'CALL') {
const HEX_BASE = 16;
const callAddress = parseInt(newStructLog.stack[0], HEX_BASE);
// HACK(leo): Geth traces sometimes returns those gas costs incorrectly as very big numbers so we manually fix them.
if (callAddress < 50) {
const nextStructLog = structLogs[idx + 1];
newStructLog.gasCost = structLog.gas - nextStructLog.gas;
} else {
newStructLog.gasCost = 700;
}
// newStructLog.gasCost = 700;
}
return newStructLog;
});
return newStructLogs;
@ -104,4 +131,7 @@ export const utils = {
lines[0] = lines[0].slice(range.start.column);
return lines.join('\n');
},
shortenHex(hex: string, length: number): string {
return `${hex.substr(0, length + 2)}...${hex.substr(hex.length - length, length)}`;
},
};

View File

@ -1597,7 +1597,7 @@
version "2.2.48"
resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-2.2.48.tgz#3523b126a0b049482e1c3c11877460f76622ffab"
"@types/node@*", "@types/node@^10.3.2":
"@types/node@*", "@types/node@10.9.4", "@types/node@^10.3.2":
version "10.9.4"
resolved "https://registry.yarnpkg.com/@types/node/-/node-10.9.4.tgz#0f4cb2dc7c1de6096055357f70179043c33e9897"
@ -13478,13 +13478,6 @@ react-copy-to-clipboard@^5.0.0:
copy-to-clipboard "^3"
prop-types "^15.5.8"
react-document-title@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/react-document-title/-/react-document-title-2.0.3.tgz#bbf922a0d71412fc948245e4283b2412df70f2b9"
dependencies:
prop-types "^15.5.6"
react-side-effect "^1.0.2"
react-dom@^16.3.2:
version "16.4.2"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-16.4.2.tgz#4afed569689f2c561d2b8da0b819669c38a0bda4"
@ -13560,8 +13553,8 @@ react-highlight@0xproject/react-highlight#react-peer-deps:
dependencies:
highlight.js "^9.11.0"
highlightjs-solidity "^1.0.5"
react "^16.4.2"
react-dom "^16.4.2"
react "^16.5.2"
react-dom "^16.5.2"
react-hot-loader@^4.3.3:
version "4.3.4"
@ -13706,7 +13699,7 @@ react-scrollable-anchor@^0.6.1:
jump.js "1.0.1"
prop-types "^15.5.10"
react-side-effect@^1.0.2, react-side-effect@^1.1.0:
react-side-effect@^1.1.0:
version "1.1.5"
resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-1.1.5.tgz#f26059e50ed9c626d91d661b9f3c8bb38cd0ff2d"
dependencies: