Skip to content

Commit 454e009

Browse files
committed
fix: dedupe cmps for typesAndNames
1 parent 1de1198 commit 454e009

2 files changed

Lines changed: 124 additions & 14 deletions

File tree

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+
});

0 commit comments

Comments
 (0)