Skip to content

Commit e7b0ee4

Browse files
mshanemcpeternhale
andauthored
fix(perf): optimize populateTypesAndNames component deduplication W-21991374 (#849)
* perf: dedupe paths in getComponentSets Use Set-backed unique lists for deletes and non-deletes before resolution. * fix: dedupe cmps for typesAndNames * chore: bump sdr for xnuts * test: prove no duplicates from react project without set deduplication * chore: exclude nut fixture from lint * chore: exclude nut fixture from compile --------- Co-authored-by: peternhale <peter.hale@salesforce.com>
1 parent 1de1198 commit e7b0ee4

File tree

120 files changed

+20557
-121
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

120 files changed

+20557
-121
lines changed

.eslintrc.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
module.exports = {
22
extends: ['eslint-config-salesforce-typescript', 'eslint-config-salesforce-license', 'plugin:sf-plugin/library'],
33
// ignore eslint files in NUT test repos
4-
ignorePatterns: ['test/nuts/ebikes-lwc'],
4+
ignorePatterns: ['test/nuts/ebikes-lwc', 'test/nuts/repros/reactinternalapp'],
55
};

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
"dependencies": {
5353
"@salesforce/core": "^8.28.1",
5454
"@salesforce/kit": "^3.2.6",
55-
"@salesforce/source-deploy-retrieve": "^12.32.3",
55+
"@salesforce/source-deploy-retrieve": "^12.32.4",
5656
"@salesforce/ts-types": "^2.0.12",
5757
"fast-xml-parser": "^5.5.7",
5858
"graceful-fs": "^4.2.11",

src/shared/populateTypesAndNames.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -69,25 +69,34 @@ export const populateTypesAndNames =
6969
resolveDeleted ? VirtualTreeContainer.fromFilePaths(filenames) : maybeGetTreeContainer(projectPath),
7070
!!forceIgnore
7171
);
72-
const sourceComponents = filenames
73-
.flatMap((filename) => {
74-
try {
75-
return resolver.getComponentsFromPath(filename);
76-
} catch (e) {
77-
logger.warn(`unable to resolve ${filename}`);
78-
return undefined;
79-
}
80-
})
81-
.filter(isDefined);
82-
83-
logger.debug(` matching SourceComponents have ${sourceComponents.length} items from local`);
84-
8572
const elementMap = new Map(
8673
elements.flatMap((e) => (e.filenames ?? []).map((f) => [ensureRelative(projectPath)(f), e]))
8774
);
8875

76+
// Deduplicate by fullName+type: all files in the same bundle component (e.g. uiBundles)
77+
// resolve to the same SourceComponent, so without dedup getAllFiles/walkContent is called
78+
// once per input file rather than once per unique component (O(N) walks instead of O(1)).
79+
const uniqueSourceComponents = [
80+
...new Map(
81+
filenames
82+
.flatMap((filename) => {
83+
try {
84+
return resolver.getComponentsFromPath(filename);
85+
} catch (e) {
86+
logger.warn(`unable to resolve ${filename}`);
87+
return undefined;
88+
}
89+
})
90+
.filter(isDefined)
91+
.filter(sourceComponentHasFullNameAndType)
92+
.map((sc) => [`${sc.fullName}:${sc.type.name}`, sc] as const)
93+
).values(),
94+
];
95+
96+
logger.debug(`populateTypesAndNames resolved ${uniqueSourceComponents.length} unique components`);
97+
8998
// iterates the local components and sets their filenames
90-
sourceComponents.filter(sourceComponentHasFullNameAndType).map((matchingComponent) => {
99+
uniqueSourceComponents.map((matchingComponent) => {
91100
const filenamesFromMatchingComponent = getAllFiles(matchingComponent);
92101
const ignored = filenamesFromMatchingComponent
93102
.filter(excludeLwcLocalOnlyTest)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Copyright 2026, Salesforce, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import path from 'node:path';
17+
import fs from 'node:fs';
18+
import os from 'node:os';
19+
import { expect } from 'chai';
20+
import { ForceIgnore, RegistryAccess } from '@salesforce/source-deploy-retrieve';
21+
import { populateTypesAndNames } from '../../../src/shared/populateTypesAndNames';
22+
import { ChangeResult } from '../../../src/shared/types';
23+
24+
// TestSession stubs process.cwd() to the project dir, which causes maybeGetTreeContainer
25+
// to return undefined and the resolver to use the real cwd (workspace root) for FS ops.
26+
// Use mkdtempSync so process.cwd() !== projectPath and the NodeFSTreeContainer(projectPath)
27+
// is used, making relative-path resolution work correctly.
28+
29+
const registry = new RegistryAccess();
30+
31+
// Relative paths matching what isogit/localShadowRepo returns
32+
const apexMeta = path.join('force-app', 'main', 'default', 'classes', 'OrderController.cls-meta.xml');
33+
const lwcDir = path.join('force-app', 'main', 'default', 'lwc');
34+
35+
describe('populateTypesAndNames', () => {
36+
let projectPath: string;
37+
38+
before(() => {
39+
projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'populateTypesAndNames-'));
40+
fs.cpSync(path.resolve(path.join('test', 'nuts', 'ebikes-lwc')), projectPath, { recursive: true });
41+
});
42+
43+
after(() => {
44+
fs.rmSync(projectPath, { recursive: true, force: true });
45+
});
46+
47+
it('returns an empty array for empty input', () => {
48+
expect(populateTypesAndNames({ projectPath, registry })([])).to.deep.equal([]);
49+
});
50+
51+
it('resolves an Apex class to its type and name', () => {
52+
const input: ChangeResult[] = [{ origin: 'local', filenames: [apexMeta] }];
53+
const [result] = populateTypesAndNames({ projectPath, registry })(input);
54+
expect(result.type).to.equal('ApexClass');
55+
expect(result.name).to.equal('OrderController');
56+
});
57+
58+
it('resolves multiple LWC bundle files to the same component type/name', () => {
59+
const input: ChangeResult[] = [
60+
{ origin: 'local', filenames: [path.join(lwcDir, 'accountMap', 'accountMap.js')] },
61+
{ origin: 'local', filenames: [path.join(lwcDir, 'accountMap', 'accountMap.html')] },
62+
];
63+
const results = populateTypesAndNames({ projectPath, registry })(input);
64+
expect(results).to.have.length(2);
65+
results.forEach((r) => {
66+
expect(r.type).to.equal('LightningComponentBundle');
67+
expect(r.name).to.equal('accountMap');
68+
});
69+
});
70+
71+
it('marks a component as ignored when a content file matches .forceignore', () => {
72+
// **/jsconfig.json is in the ebikes .forceignore. Writing one inside the bundle
73+
// means forceIgnoreDenies returns true for this component.
74+
const createCaseDir = path.join(projectPath, lwcDir, 'createCase');
75+
fs.writeFileSync(path.join(createCaseDir, 'jsconfig.json'), '{}');
76+
77+
const forceIgnore = ForceIgnore.findAndCreate(projectPath);
78+
const input: ChangeResult[] = [
79+
{ origin: 'local', filenames: [path.join(lwcDir, 'createCase', 'createCase.js-meta.xml')] },
80+
];
81+
const [result] = populateTypesAndNames({ projectPath, registry, forceIgnore })(input);
82+
expect(result.ignored).to.equal(true);
83+
});
84+
85+
it('excludes unresolvable filenames when excludeUnresolvable is true', () => {
86+
const input: ChangeResult[] = [
87+
{ origin: 'local', filenames: ['force-app/main/default/classes/DoesNotExist.cls-meta.xml'] },
88+
];
89+
expect(populateTypesAndNames({ projectPath, registry, excludeUnresolvable: true })(input)).to.deep.equal([]);
90+
});
91+
92+
it('preserves unresolvable elements when excludeUnresolvable is false', () => {
93+
const input: ChangeResult[] = [
94+
{ origin: 'local', filenames: ['force-app/main/default/classes/DoesNotExist.cls-meta.xml'] },
95+
];
96+
const [result] = populateTypesAndNames({ projectPath, registry })(input);
97+
expect(result.origin).to.equal('local');
98+
expect(result.type).to.equal(undefined);
99+
expect(result.name).to.equal(undefined);
100+
});
101+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright 2026, Salesforce, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import path from 'node:path';
17+
import fs from 'node:fs';
18+
import { execSync } from 'node:child_process';
19+
import { TestSession } from '@salesforce/cli-plugins-testkit';
20+
import { expect } from 'chai';
21+
import { RegistryAccess } from '@salesforce/source-deploy-retrieve';
22+
import { ShadowRepo } from '../../../src/shared/local/localShadowRepo';
23+
import { getGroupedFiles, getComponentSets } from '../../../src/shared/localComponentSetArray';
24+
25+
const findUiBundleDir = (projectDir: string): string => {
26+
const uiBundlesRoot = path.join(projectDir, 'force-app', 'main', 'default', 'uiBundles');
27+
const entries = fs.readdirSync(uiBundlesRoot, { withFileTypes: true });
28+
const first = entries.find((e) => e.isDirectory() && !e.name.startsWith('.'));
29+
if (!first) throw new Error(`No uiBundle directory found under ${uiBundlesRoot}`);
30+
return path.join(uiBundlesRoot, first.name);
31+
};
32+
33+
describe('reactinternalapp template: getComponentSets dedup check', () => {
34+
let session: TestSession;
35+
let projectPath: string;
36+
const registry = new RegistryAccess();
37+
const pkgDir = 'force-app';
38+
39+
before(async () => {
40+
session = await TestSession.create({
41+
project: {
42+
sourceDir: path.join('test', 'nuts', 'repros', 'reactinternalapp'),
43+
},
44+
devhubAuthStrategy: 'NONE',
45+
});
46+
projectPath = session.project.dir;
47+
execSync('npm install --registry https://registry.npmjs.org/', {
48+
cwd: findUiBundleDir(projectPath),
49+
stdio: 'inherit',
50+
});
51+
});
52+
53+
after(async () => {
54+
await session?.clean();
55+
});
56+
57+
it('single pkgDir: no duplicate filenames in groupings', async () => {
58+
const repo = await ShadowRepo.getInstance({
59+
orgId: 'fakeOrgId-reactapp-single',
60+
projectPath,
61+
packageDirs: [{ path: pkgDir, name: pkgDir, fullPath: path.join(projectPath, pkgDir) }],
62+
registry,
63+
});
64+
65+
const [nonDeletes, deletes] = await Promise.all([repo.getNonDeleteFilenames(), repo.getDeleteFilenames()]);
66+
67+
// All files are new (not committed), so deletes should be empty
68+
expect(deletes).to.have.lengthOf(0);
69+
expect(nonDeletes.length).to.be.greaterThan(0);
70+
71+
const groupings = getGroupedFiles(
72+
{
73+
packageDirs: [{ path: pkgDir, name: pkgDir, fullPath: path.join(projectPath, pkgDir) }],
74+
nonDeletes,
75+
deletes,
76+
},
77+
false
78+
);
79+
80+
expect(groupings).to.have.lengthOf(1);
81+
// No duplicates: grouping should have exactly the same count as the raw filenames
82+
expect(groupings[0].nonDeletes.length).to.equal(nonDeletes.length);
83+
84+
// Calling getComponentSets triggers the instrumented lines
85+
getComponentSets({ groupings, registry, projectPath });
86+
});
87+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status
2+
# More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm
3+
#
4+
5+
package.xml
6+
7+
# LWC configuration files
8+
**/jsconfig.json
9+
**/.eslintrc.json
10+
11+
# LWC Jest
12+
**/__tests__/**
13+
14+
node_modules/
15+
.DS_Store
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: '../../../../../schema.graphql'
2+
documents: 'src/**/*.{graphql,js,ts,jsx,tsx}'
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
node_modules
2+
dist
3+
build
4+
.vite
5+
coverage
6+
*.min.js
7+
*.min.css
8+
*.map
9+
package-lock.json
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"semi": true,
3+
"trailingComma": "es5",
4+
"singleQuote": true,
5+
"printWidth": 80,
6+
"tabWidth": 2,
7+
"useTabs": false,
8+
"bracketSpacing": true,
9+
"arrowParens": "avoid",
10+
"endOfLine": "lf"
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Change Log
2+
3+
All notable changes to this project will be documented in this file.
4+
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5+
6+
# [1.59.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.58.2...v1.59.0) (2026-02-27)
7+
8+
### Features
9+
10+
- auto bump base react app versions and fix issue with base ui-bundle json ([#175](https://github.com/salesforce-experience-platform-emu/webapps/issues/175)) ([048b5a8](https://github.com/salesforce-experience-platform-emu/webapps/commit/048b5a8449c899fc923aeebc3c76bc5bf1c5e0d4))

0 commit comments

Comments
 (0)