Skip to content

Commit 4c7ba47

Browse files
authored
Remove lodash usage (#1086)
1 parent d0bc43c commit 4c7ba47

12 files changed

Lines changed: 187 additions & 38 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "Remove lodash usage for minor perf improvement",
4+
"packageName": "beachball",
5+
"email": "elcraig@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@
5050
"cosmiconfig": "^9.0.0",
5151
"execa": "^5.0.0",
5252
"fs-extra": "^11.1.1",
53-
"lodash": "^4.17.15",
5453
"minimatch": "^3.0.4",
5554
"p-graph": "^1.1.2",
5655
"p-limit": "^3.0.2",
@@ -63,7 +62,6 @@
6362
"devDependencies": {
6463
"@jest/globals": "^29.0.0",
6564
"@types/fs-extra": "^11.0.0",
66-
"@types/lodash": "^4.14.191",
6765
"@types/minimatch": "^5.0.0",
6866
"@types/node": "^14.0.0",
6967
"@types/prompts": "^2.4.2",

src/__fixtures__/mockNpm.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { afterAll, afterEach, beforeAll, type jest } from '@jest/globals';
22
import fs from 'fs-extra';
3-
import _ from 'lodash';
43
import path from 'path';
54
import semver from 'semver';
65
import { npmShowProperties, type NpmShowResult } from '../packageManager/listPackageVersions';
@@ -122,7 +121,9 @@ export function initNpmMock(): NpmMock {
122121

123122
/** (exported for testing) Make full registry data from partial data */
124123
export function _makeRegistryData(data: PartialRegistryData): MockNpmRegistry {
125-
return _.mapValues(data, (pkg, name): MockNpmRegistryPackage => {
124+
const registry: MockNpmRegistry = {};
125+
126+
for (const [name, pkg] of Object.entries(data)) {
126127
let versions = pkg.versions;
127128
let distTags = pkg['dist-tags'];
128129
if (!versions && !distTags) {
@@ -136,13 +137,15 @@ export function _makeRegistryData(data: PartialRegistryData): MockNpmRegistry {
136137
// Ensure "latest" is set
137138
distTags.latest ??= versions.slice(-1)[0];
138139

139-
return {
140+
registry[name] = {
140141
versions,
141142
'dist-tags': distTags,
142143
// Fill in basic package.json data for each version
143144
versionData: Object.fromEntries(versions.map(version => [version, { name, version }])),
144145
};
145-
});
146+
}
147+
148+
return registry;
146149
}
147150

148151
/** (exported for testing) Mock npm show based on the registry data */

src/__fixtures__/packageInfos.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import _ from 'lodash';
21
import type { BeachballOptions } from '../types/BeachballOptions';
32
import type { PackageInfo, PackageInfos } from '../types/PackageInfo';
43
import { getDefaultOptions } from '../options/getDefaultOptions';
@@ -23,9 +22,10 @@ export type PartialPackageInfos = {
2322
* ```
2423
*/
2524
export function makePackageInfos(packageInfos: PartialPackageInfos): PackageInfos {
26-
return _.mapValues(packageInfos, (info, name): PackageInfo => {
25+
const acc: PackageInfos = {};
26+
for (const [name, info] of Object.entries(packageInfos)) {
2727
const { combinedOptions, ...rest } = info;
28-
return {
28+
acc[name] = {
2929
name,
3030
version: '1.0.0',
3131
private: false,
@@ -34,5 +34,6 @@ export function makePackageInfos(packageInfos: PartialPackageInfos): PackageInfo
3434
packageJsonPath: '',
3535
...rest,
3636
};
37-
});
37+
}
38+
return acc;
3839
}

src/__fixtures__/repositoryFactory.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { tmpdir } from './tmpdir';
77
import { gitFailFast } from 'workspace-tools';
88
import { setDefaultBranchName } from './gitDefaults';
99
import { env } from '../env';
10-
import _ from 'lodash';
10+
import { _cloneObject } from '../publish/cloneBumpInfo';
1111

1212
/**
1313
* Standard fixture options. See {@link getSinglePackageFixture}, {@link getMonorepoFixture} and
@@ -177,7 +177,7 @@ export class RepositoryFactory {
177177
: fixtureParam === 'monorepo'
178178
? getMonorepoFixture()
179179
: // Clone the user-provided fixture so it's safe to modify
180-
_.cloneDeep(fixtureParam),
180+
_cloneObject(fixtureParam),
181181
};
182182
}
183183

src/__tests__/packageManager/listPackageVersions.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { afterEach, describe, expect, it, jest } from '@jest/globals';
2-
import _ from 'lodash';
32
import {
43
listPackageVersions,
54
listPackageVersionsByTag,
@@ -51,7 +50,8 @@ describe('list npm versions', () => {
5150
npmMock.setRegistryData(showData);
5251

5352
const versions = await listPackageVersions(packages, npmOptions);
54-
expect(versions).toEqual(_.mapValues(showData, x => x.versions));
53+
const expectedVerions = Object.fromEntries(Object.entries(showData).map(([k, v]) => [k, v.versions]));
54+
expect(versions).toEqual(expectedVerions);
5555
expect(npmMock.mock).toHaveBeenCalledTimes(packages.length);
5656
});
5757

@@ -123,10 +123,10 @@ describe('list npm versions', () => {
123123
const packages = 'abcdefghij'.split('');
124124
const showData = Object.fromEntries(packages.map((x, i) => [x, { 'dist-tags': { latest: `${i}.0.0` } }]));
125125
npmMock.setRegistryData(showData);
126-
const packageInfos = Object.values(makePackageInfos(_.mapValues(showData, () => ({}))));
126+
const packageInfos = Object.values(makePackageInfos(Object.fromEntries(packages.map(x => [x, {}]))));
127127

128128
expect(await listPackageVersionsByTag(packageInfos, 'latest', npmOptions)).toEqual(
129-
_.mapValues(showData, x => x['dist-tags']?.latest)
129+
Object.fromEntries(Object.entries(showData).map(([k, v]) => [k, v['dist-tags'].latest]))
130130
);
131131
expect(npmMock.mock).toHaveBeenCalledTimes(packages.length);
132132
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, it, expect } from '@jest/globals';
2+
import { _cloneObject, cloneBumpInfo } from '../../publish/cloneBumpInfo';
3+
import type { BumpInfo } from '../../types/BumpInfo';
4+
import { makePackageInfos } from '../../__fixtures__/packageInfos';
5+
import { getChange } from '../../__fixtures__/changeFiles';
6+
7+
describe('_cloneObject', () => {
8+
it.each<[string, object | unknown[]]>([
9+
['empty object', {}],
10+
['empty object with null prototype', Object.create(null)],
11+
['object', { a: 1, b: '2', c: true }],
12+
['object with null prototype', Object.assign(Object.create(null), { a: 1, b: '2', c: true })],
13+
['empty array', []],
14+
['array', [1, '2', true]],
15+
['set', new Set([1, 2, 3])],
16+
['object of sets', { a: new Set([1, 2, 3]), b: new Set(['a', 'b', 'c']) }],
17+
])('clones %s', (desc, val) => {
18+
const cloned = _cloneObject(val);
19+
expect(cloned).toEqual(val);
20+
expect(cloned).not.toBe(val);
21+
});
22+
23+
it('deeply clones nested object', () => {
24+
const orig = { a: { b: { c: 1 } } };
25+
const cloned = _cloneObject(orig);
26+
expect(cloned).toEqual(orig);
27+
expect(cloned).not.toBe(orig);
28+
expect(cloned.a).not.toBe(orig.a);
29+
expect(cloned.a.b).not.toBe(orig.a.b);
30+
});
31+
32+
it('deep clones array of objects and arrays', () => {
33+
const orig = [{ a: 1 }, [2, 3, 4]];
34+
const cloned = _cloneObject(orig);
35+
expect(cloned).toEqual(orig);
36+
expect(cloned).not.toBe(orig);
37+
expect(cloned[0]).not.toBe(orig[0]);
38+
expect(cloned[1]).not.toBe(orig[1]);
39+
});
40+
41+
it('throws on other object types', () => {
42+
expect(() => _cloneObject(new Date())).toThrow('Unsupported object type found while cloning bump info: Date');
43+
expect(() => _cloneObject(/abc/)).toThrow('Unsupported object type found while cloning bump info: RegExp');
44+
expect(() => _cloneObject(new Map())).toThrow('Unsupported object type found while cloning bump info: Map');
45+
class Foo {}
46+
expect(() => _cloneObject(new Foo())).toThrow('Unsupported object type found while cloning bump info: Foo');
47+
});
48+
});
49+
50+
describe('cloneBumpInfo', () => {
51+
it('clones bump info structure', () => {
52+
const original: BumpInfo = {
53+
// There's no attempt at consistency because it doesn't matter here
54+
calculatedChangeTypes: { pkgA: 'minor', pkgB: 'patch' },
55+
packageInfos: makePackageInfos({ a: { dependencies: { b: '^1.0.0' } }, b: {} }),
56+
changeFileChangeInfos: [
57+
{ change: getChange('a'), changeFile: '' },
58+
{ change: getChange('b'), changeFile: '' },
59+
],
60+
packageGroups: { group1: { packageNames: ['a', 'b'], disallowedChangeTypes: null } },
61+
dependentChangedBy: { a: new Set(['b']) },
62+
modifiedPackages: new Set(['a']),
63+
scopedPackages: new Set(['a', 'b']),
64+
};
65+
66+
const cloned = cloneBumpInfo(original);
67+
expect(cloned).toEqual(original);
68+
expect(cloned).not.toBe(original);
69+
expect(cloned.packageInfos).not.toBe(original.packageInfos);
70+
expect(cloned.packageInfos.a).not.toBe(original.packageInfos.a);
71+
expect(cloned.changeFileChangeInfos).not.toBe(original.changeFileChangeInfos);
72+
expect(cloned.changeFileChangeInfos[0]).not.toBe(original.changeFileChangeInfos[0]);
73+
expect(cloned.changeFileChangeInfos[0].change).not.toBe(original.changeFileChangeInfos[0].change);
74+
expect(cloned.packageGroups).not.toBe(original.packageGroups);
75+
expect(cloned.dependentChangedBy).not.toBe(original.dependentChangedBy);
76+
expect(cloned.dependentChangedBy.a).not.toBe(original.dependentChangedBy.a);
77+
expect(cloned.modifiedPackages).not.toBe(original.modifiedPackages);
78+
expect(cloned.scopedPackages).not.toBe(original.scopedPackages);
79+
});
80+
});

src/__tests__/publish/performPublishOverrides.test.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { describe, expect, it, afterEach, jest } from '@jest/globals';
22
import * as fs from 'fs-extra';
3-
import _ from 'lodash';
43
import { performPublishOverrides } from '../../publish/performPublishOverrides';
54
import type { PackageInfos, PackageJson, PublishConfig } from '../../types/PackageInfo';
6-
import { makePackageInfos } from '../../__fixtures__/packageInfos';
5+
import { makePackageInfos, type PartialPackageInfos } from '../../__fixtures__/packageInfos';
76

87
jest.mock('fs-extra', () => ({
98
readJSONSync: jest.fn(),
@@ -23,29 +22,35 @@ describe('performPublishOverrides', () => {
2322
packageInfos: PackageInfos;
2423
packageJsons: Record<string, PackageJson>;
2524
} {
26-
const packageInfos = makePackageInfos(
27-
_.mapValues(partialPackageJsons, (json, name) => ({
25+
const partialInfos: PartialPackageInfos = {};
26+
for (const [name, json] of Object.entries(partialPackageJsons)) {
27+
partialInfos[name] = {
2828
packageJsonPath: `packages/${name}/package.json`,
2929
version: json.version || '1.0.0',
3030
dependencies: json.dependencies || {},
31-
}))
32-
);
33-
const packageJsons: Record<string, PackageJson> = _.mapValues(partialPackageJsons, (json, name) => ({
34-
name,
35-
version: packageInfos[name].version,
36-
// these values can potentially be overridden by publishConfig
37-
main: 'src/index.ts',
38-
bin: 'src/foo-bin.ts',
39-
...json,
40-
}));
31+
};
32+
}
33+
const packageInfos = makePackageInfos(partialInfos);
34+
35+
const packageJsons: Record<string, PackageJson> = {};
36+
for (const [name, json] of Object.entries(partialPackageJsons)) {
37+
packageJsons[name] = {
38+
name,
39+
version: packageInfos[name].version,
40+
// these values can potentially be overridden by publishConfig
41+
main: 'src/index.ts',
42+
bin: 'src/foo-bin.ts',
43+
...json,
44+
};
45+
}
4146

4247
readJSONSync.mockImplementation(path => {
4348
for (const pkg of Object.values(packageInfos)) {
4449
if (path === pkg.packageJsonPath) {
4550
// performPublishConfigOverrides mutates the packageJson, so we need to clone it to
4651
// simulate reading the file from the disk and avoid mutating original fixtures.
4752
// This is also just safer in general for tests that use this method for before/after comparisons.
48-
return _.cloneDeep(packageJsons[pkg.name]);
53+
return JSON.parse(JSON.stringify(packageJsons[pkg.name]));
4954
}
5055
}
5156
throw new Error(`not found: ${path as string}`);

src/changelog/renderPackageChangelog.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { ChangelogEntry } from '../types/ChangeLog';
2-
import _ from 'lodash';
32
import type { PackageChangelogRenderInfo, ChangelogRenderers } from '../types/ChangelogOptions';
43
import type { ChangeType } from '../types/ChangeInfo';
54
import { SortedChangeTypes } from '../changefile/changeTypes';
@@ -67,11 +66,14 @@ async function _renderEntries(changeType: ChangeType, renderInfo: PackageChangel
6766
}
6867

6968
if (renderInfo.isGrouped) {
70-
const entriesByPackage = _.entries(_.groupBy(entries, entry => entry.package));
69+
const entriesByPackage: Record<string, ChangelogEntry[]> = {};
70+
for (const entry of entries) {
71+
(entriesByPackage[entry.package] ??= []).push(entry);
72+
}
7173

7274
// Use a for loop here (not map) so that if renderEntry does network requests, we don't fire them all at once
7375
const packagesText: string[] = [];
74-
for (const [pkgName, pkgEntries] of entriesByPackage) {
76+
for (const [pkgName, pkgEntries] of Object.entries(entriesByPackage)) {
7577
const entriesText = (await _renderEntriesBasic(pkgEntries, renderInfo)).map(entry => ` ${entry}`).join('\n');
7678

7779
packagesText.push(`- \`${pkgName}\`\n${entriesText}`);

src/publish/cloneBumpInfo.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { BumpInfo } from '../types/BumpInfo';
2+
3+
/**
4+
* Clone a bump info object. Only handles the data types found in bump info.
5+
*
6+
* This is decently faster than `structuredClone` or `JSON.parse(JSON.stringify())` on a
7+
* very large object. https://jsperf.app/rugosa/5
8+
*/
9+
export function cloneBumpInfo(oldInfo: BumpInfo): BumpInfo {
10+
return _cloneObject(oldInfo);
11+
}
12+
13+
/**
14+
* Clone an object, fast.
15+
* Currently only handles data types expected in `BumpInfo` but could be expanded if needed.
16+
*
17+
* This is decently faster than `structuredClone` or `JSON.parse(JSON.stringify())` on a
18+
* very large object (bump info can be huge in certain repos). https://jsperf.app/rugosa/5
19+
*
20+
* @internal Exported for testing (and usage in tests)
21+
*/
22+
export function _cloneObject<T extends unknown[]>(obj: T): T;
23+
export function _cloneObject<T extends object>(obj: T): T;
24+
export function _cloneObject<T extends object>(obj: T): T {
25+
if (!obj || typeof obj !== 'object') {
26+
return obj;
27+
}
28+
29+
if (Array.isArray(obj)) {
30+
const clone = [] as typeof obj;
31+
for (let i = 0; i < obj.length; i++) {
32+
const val = obj[i];
33+
// Skip the recursive call if not an object.
34+
// This check is repeatedly done inline on sub-properties for performance.
35+
clone[i] = val && typeof val === 'object' ? _cloneObject(val) : val;
36+
}
37+
return clone;
38+
}
39+
40+
if (obj instanceof Set) {
41+
return new Set(Array.from(obj).map(item => (item && typeof item === 'object' ? _cloneObject(item) : item))) as T;
42+
}
43+
44+
if (obj.constructor?.name && obj.constructor.name !== 'Object') {
45+
throw new Error(`Unsupported object type found while cloning bump info: ${obj.constructor.name}`);
46+
}
47+
48+
const clone = {} as typeof obj;
49+
for (const [key, val] of Object.entries(obj)) {
50+
(clone as any)[key] = val && typeof val === 'object' ? _cloneObject(val) : val;
51+
}
52+
return clone;
53+
}

0 commit comments

Comments
 (0)