From a86b5d2aabdb38287d54fb5a93fa20ea321f0b6d Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 15 Sep 2026 11:05:14 -0600 Subject: [PATCH 01/10] perf: avoid materializing lazy collection in sizeOfComponentSet Use ComponentSet.size getter instead of getSourceComponents().toArray().length. Avoids iterating all source components and allocating a full array just to get a count used for polling frequency calculation. --- src/client/metadataTransfer.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/client/metadataTransfer.ts b/src/client/metadataTransfer.ts index 5dae27e76..c52ee679e 100644 --- a/src/client/metadataTransfer.ts +++ b/src/client/metadataTransfer.ts @@ -400,8 +400,7 @@ export const normalizePollingInputs = ( return pollingOptions; }; -/** yeah, there's a size property on CS. But we want the actual number of source components */ -const sizeOfComponentSet = (cs?: ComponentSet): number => cs?.getSourceComponents().toArray().length ?? 0; +const sizeOfComponentSet = (cs?: ComponentSet): number => cs?.size ?? 0; /** based on the size of the deploy (zip bytes for large deploys, component count for small ones), pick a reasonable polling frequency */ export const calculatePollingFrequency = (componentCount: number, zipSize?: number): number => { From 4c9c7d59bd6b1924fa9d9132e55ee070cf0a0101 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 15 Sep 2026 11:06:09 -0600 Subject: [PATCH 02/10] perf: avoid toArray() in buildFileResponsesFromComponentSet Spread the lazy collection directly instead of calling .toArray() which allocates an intermediate array. The flatMap only needs iteration, not random access. --- src/client/metadataApiDeploy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/metadataApiDeploy.ts b/src/client/metadataApiDeploy.ts index 8945d4157..ec71d4689 100644 --- a/src/client/metadataApiDeploy.ts +++ b/src/client/metadataApiDeploy.ts @@ -525,7 +525,7 @@ const buildFileResponsesFromComponentSet = (response: MetadataApiDeployStatus): FileResponse[] => { const responseMessages = getDeployMessages(response); - const fileResponses: FileResponse[] = (cs.getSourceComponents().toArray() ?? []) + const fileResponses: FileResponse[] = [...cs.getSourceComponents()] .flatMap((deployedComponent): FileResponse[] => { // UIBundle bundles get per-file status via UIBundleResource messages if ( From 8a35aae5d80c59a5643183d9a27a2ad9633545c7 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 15 Sep 2026 11:08:34 -0600 Subject: [PATCH 03/10] perf: hoist regex literals to module scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move RegExp allocations out of hot-path functions (parseMetadataXml, getSuggestionsForUnresolvedTypes, parseAsFolderMetadataXml, and baseSourceAdapter resolution) into module-level constants. These functions are called thousands of times during resolution — once per file per resolution attempt. --- src/resolve/adapters/baseSourceAdapter.ts | 7 +++++-- src/resolve/metadataResolver.ts | 7 +++++-- src/utils/path.ts | 4 +++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/resolve/adapters/baseSourceAdapter.ts b/src/resolve/adapters/baseSourceAdapter.ts index 65ed3ceee..c4f188181 100644 --- a/src/resolve/adapters/baseSourceAdapter.ts +++ b/src/resolve/adapters/baseSourceAdapter.ts @@ -29,6 +29,9 @@ import { RegistryAccess } from '../../registry/registryAccess'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); +const NAME_AND_SUFFIX_REGEX = /(.+)\.(.+)/; +const FOLDER_META_XML_STRICT_REGEX = /(.+)-meta\.xml$/; + export abstract class BaseSourceAdapter implements SourceAdapter { protected type: MetadataType; protected registry: RegistryAccess; @@ -191,14 +194,14 @@ const parseAsContentMetadataXml = return undefined; } - const match = new RegExp(/(.+)\.(.+)/).exec(basename(path)); + const match = NAME_AND_SUFFIX_REGEX.exec(basename(path)); if (match && type.suffix === match[2]) { return { fullName: match[1], suffix: match[2], path }; } }; const parseAsFolderMetadataXml = (fsPath: SourcePath): MetadataXml | undefined => { - const match = new RegExp(/(.+)-meta\.xml$/).exec(basename(fsPath)); + const match = FOLDER_META_XML_STRICT_REGEX.exec(basename(fsPath)); const parts = fsPath.split(sep); if (match && !match[1].includes('.') && parts.length > 1) { return { fullName: match[1], suffix: undefined, path: fsPath }; diff --git a/src/resolve/metadataResolver.ts b/src/resolve/metadataResolver.ts index 84d09accf..c6b2b7e46 100644 --- a/src/resolve/metadataResolver.ts +++ b/src/resolve/metadataResolver.ts @@ -31,6 +31,9 @@ import { NodeFSTreeContainer, TreeContainer } from './treeContainers'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); +const CLOSE_META_SUFFIX_REGEX = /.+\.([^.-]+)(?:-.*)?\.xml/; +const FOLDER_META_XML_REGEX = /(.+)-meta\.xml/; + /** * Resolver for metadata type and component objects. * @@ -262,7 +265,7 @@ const getSuggestionsForUnresolvedTypes = const metaSuffix = parsedMetaXml?.suffix; // Finds close matches for meta suffixes // Examples: https://regex101.com/r/vbRjwy/1 - const closeMetaSuffix = new RegExp(/.+\.([^.-]+)(?:-.*)?\.xml/).exec(basename(fsPath)); + const closeMetaSuffix = CLOSE_META_SUFFIX_REGEX.exec(basename(fsPath)); let guesses; @@ -307,7 +310,7 @@ const parseAsFolderMetadataXml = (registry: RegistryAccess) => (fsPath: string): string | undefined => { let folderName: string | undefined; - const match = new RegExp(/(.+)-meta\.xml/).exec(basename(fsPath)); + const match = FOLDER_META_XML_REGEX.exec(basename(fsPath)); if (match && !match[1].includes('.')) { const parts = fsPath.split(sep); if (parts.length > 1) { diff --git a/src/utils/path.ts b/src/utils/path.ts index e79c2808f..d5fee2917 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -96,8 +96,10 @@ export function trimUntil(fsPath: SourcePath, part: string, untilLast = false): * @param fsPath - File path to parse * @returns MetadataXml info or undefined */ +const METADATA_XML_REGEX = /(.+)\.(.+)-meta\.xml/; + export function parseMetadataXml(fsPath: string): Optional { - const match = new RegExp(/(.+)\.(.+)-meta\.xml/).exec(basename(fsPath)); + const match = METADATA_XML_REGEX.exec(basename(fsPath)); if (match) { return { fullName: match[1], suffix: match[2], path: fsPath }; } From 6d59a7a6abc122b400a3a97caf3ee77ac88b7269 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 15 Sep 2026 11:15:35 -0600 Subject: [PATCH 04/10] perf: use composite key instead of JSON.stringify for FileResponse dedup Replace JSON.stringify-based deduplication in getFileResponses with a composite string key built from the response fields. Avoids serializing every FileResponse object to JSON just to use as a Map key. --- src/client/metadataApiDeploy.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/client/metadataApiDeploy.ts b/src/client/metadataApiDeploy.ts index ec71d4689..5c3234387 100644 --- a/src/client/metadataApiDeploy.ts +++ b/src/client/metadataApiDeploy.ts @@ -68,12 +68,15 @@ export class DeployResult implements MetadataTransferResult { // this involves FS operations, so only perform once! if (!this.fileResponses) { this.fileResponses = [ - // removes duplicates from the file responses by parsing the object into a string, used as the key of the map ...new Map( (this.components ? buildFileResponsesFromComponentSet(this.components)(this.response) : buildFileResponses(this.response) - ).map((v) => [JSON.stringify(v), v]) + ).map((v) => { + const base = `${v.type}#${v.fullName}#${v.filePath ?? ''}#${v.state}`; + const key = 'error' in v ? `${base}#${v.error}#${String(v.lineNumber)}#${String(v.columnNumber)}` : base; + return [key, v] as const; + }) ).values(), ]; } From affbb253473bb1f2192d76b71d032a26cfc6ecd3 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 15 Sep 2026 11:16:44 -0600 Subject: [PATCH 05/10] perf: use Set lookup in warnIfUnmatchedServerResult Replace O(n*m) linear scan with a Set of type#fullName keys for O(1) lookups. Also extract manifest file names into a module-level Set. For large deploys with hundreds of messages and file responses, this eliminates hundreds of thousands of comparisons. --- src/client/metadataApiDeploy.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/client/metadataApiDeploy.ts b/src/client/metadataApiDeploy.ts index 5c3234387..60cbb19fd 100644 --- a/src/client/metadataApiDeploy.ts +++ b/src/client/metadataApiDeploy.ts @@ -468,24 +468,26 @@ const deleteNotFoundToFileResponses = : []; }); +const MANIFEST_FILES = new Set([ + 'package.xml', + 'destructiveChanges.xml', + 'destructiveChangesPost.xml', + 'destructiveChangesPre.xml', +]); + const warnIfUnmatchedServerResult = (fr: FileResponse[]) => - (messageMap: Map): void[] => - // keep the parents and children separated for MPD scenarios where we have a parent in one, children in another package - [...messageMap.keys()].flatMap((key) => { + (messageMap: Map): void[] => { + const frKeys = new Set(fr.map((c) => `${c.type}#${c.fullName}`)); + + return [...messageMap.keys()].flatMap((key) => { const [type, fullName] = key.split('#', 2); // UIBundleResource messages are already handled by the parent UIBundle component const consumedByWebApp = type === 'UIBundleResource' && fr.some((c) => c.type === 'UIBundle' && fullName.startsWith(`${c.fullName}/`)); - if ( - !consumedByWebApp && - !fr.find((c) => c.type === type && c.fullName === fullName) && - !['package.xml', 'destructiveChanges.xml', 'destructiveChangesPost.xml', 'destructiveChangesPre.xml'].includes( - fullName - ) - ) { + if (!consumedByWebApp && !frKeys.has(key) && !MANIFEST_FILES.has(fullName)) { const deployMessage = messageMap.get(key)!.at(0)!; // Don't warn for deleted components - not found in the component set (pre-destructiveChanges) @@ -501,6 +503,7 @@ const warnIfUnmatchedServerResult = ); } }); + }; const buildFileResponses = (response: MetadataApiDeployStatus): FileResponse[] => ensureArray(response.details?.componentSuccesses) .concat(ensureArray(response.details?.componentFailures)) From bbe2843720bf5ac421b7cbeb3fa23538e4b049eb Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 15 Sep 2026 11:20:27 -0600 Subject: [PATCH 06/10] perf: remove redundant statSync guard in NodeFSTreeContainer.readDirectory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the upfront statSync().isDirectory() guard with a try/catch on readdirSync. This eliminates one syscall per readDirectory call — and since callers typically check isDirectory first, it was a double stat on every directory in the resolution tree. --- src/resolve/treeContainers.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/resolve/treeContainers.ts b/src/resolve/treeContainers.ts index e0a8e6fba..281dc7eec 100644 --- a/src/resolve/treeContainers.ts +++ b/src/resolve/treeContainers.ts @@ -124,9 +124,13 @@ export class NodeFSTreeContainer extends TreeContainer { } public readDirectory(fsPath: SourcePath): string[] { - const resolved = this.getUpdatedFsPath(fsPath); - if (!statSync(resolved).isDirectory()) return []; - return readdirSync(resolved); + try { + return readdirSync(this.getUpdatedFsPath(fsPath)); + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code === 'ENOTDIR' || code === 'ENOENT') return []; + throw e; + } } public readFile(fsPath: SourcePath): Promise { From cc9757352f47de429937532385869009d7c5782f Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 15 Sep 2026 11:26:28 -0600 Subject: [PATCH 07/10] perf: memoize getApiVersion() in ComponentSet The method resolves API version through a multi-step fallback chain (project config, ConfigAggregator, REST call). Cache the result to avoid redundant lookups on repeated calls. --- src/collections/componentSet.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index 3566dd9a2..da5ab92ae 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -91,6 +91,7 @@ export class ComponentSet extends LazyCollection { public botVersionFilters?: Array<{ botName: string; versionFilter: 'all' | 'highest' | number }>; private logger: Logger; private readonly registry: RegistryAccess; + private resolvedApiVersion?: string; // all components stored here, regardless of what manifest they belong to private components = new DecodeableMap>(); @@ -522,7 +523,7 @@ export class ComponentSet extends LazyCollection { }); const toParse = await this.getObject(destructiveType); toParse.Package[XML_NS_KEY] = XML_NS_URL; - + return XML_DECL.concat(builder.build(toParse)); } @@ -753,6 +754,8 @@ export class ComponentSet extends LazyCollection { * @returns string The resolved API version to use in a manifest */ private async getApiVersion(): Promise { + if (this.resolvedApiVersion) return this.resolvedApiVersion; + let version = this.sourceApiVersion ?? this.apiVersion; if (!version) { @@ -781,6 +784,7 @@ export class ComponentSet extends LazyCollection { this.logger.warn(messages.getMessage('missingApiVersion')); } } + this.resolvedApiVersion = version; return version; } } From 91e5f5fcfe19de6aed9617e5a8de1a3cbec552bb Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 15 Sep 2026 11:31:07 -0600 Subject: [PATCH 08/10] perf: index ZipTreeContainer directory children at construction readDirectory previously scanned all zip entries on every call. Build a Map in the constructor for O(1) lookups. --- src/resolve/treeContainers.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/resolve/treeContainers.ts b/src/resolve/treeContainers.ts index 281dc7eec..a3c5371b4 100644 --- a/src/resolve/treeContainers.ts +++ b/src/resolve/treeContainers.ts @@ -156,15 +156,33 @@ export class NodeFSTreeContainer extends TreeContainer { export class ZipTreeContainer extends TreeContainer { private zip: JSZip; private zipKeyMap: Map = new Map(); + private dirChildren: Map = new Map(); private constructor(zip: JSZip) { super(); this.zip = zip; for (const key of Object.keys(this.zip.files)) { if (key.endsWith('/')) { - this.zipKeyMap.set(key.slice(0, -1), key); + const normalized = key.slice(0, -1); + this.zipKeyMap.set(normalized, key); + const dir = dirname(normalized); + if (dir !== normalized) { + let children = this.dirChildren.get(dir); + if (!children) { + children = []; + this.dirChildren.set(dir, children); + } + children.push(basename(normalized)); + } } else { this.zipKeyMap.set(key, key); + const dir = dirname(key); + let children = this.dirChildren.get(dir); + if (!children) { + children = []; + this.dirChildren.set(dir, children); + } + children.push(basename(key)); } } } @@ -189,12 +207,8 @@ export class ZipTreeContainer extends TreeContainer { public readDirectory(fsPath: string): string[] { const resolvedPath = this.match(fsPath); if (resolvedPath && this.ensureDirectory(resolvedPath)) { - // Remove trailing path sep if it exists. JSZip always adds them for directories but - // when comparing we call `dirname()` which does not include them. const dirPath = resolvedPath.endsWith('/') ? resolvedPath.slice(0, -1) : resolvedPath; - return Object.keys(this.zip.files) - .filter((filePath) => dirname(filePath) === dirPath) - .map((filePath) => basename(filePath)); + return this.dirChildren.get(dirPath) ?? []; } throw new SfError(messages.getMessage('error_expected_directory_path', [fsPath]), 'LibraryError'); } From 565cc09314331018f3c379da542766b0f58f2026 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 15 Sep 2026 11:39:34 -0600 Subject: [PATCH 09/10] fix: revert sizeOfComponentSet to use getSourceComponents() cs.size counts all map entries including non-SourceComponent metadata, while getSourceComponents() filters to only resolved source-backed components. The count must reflect actual source components for accurate polling frequency calculation. --- src/client/metadataTransfer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/client/metadataTransfer.ts b/src/client/metadataTransfer.ts index c52ee679e..5dae27e76 100644 --- a/src/client/metadataTransfer.ts +++ b/src/client/metadataTransfer.ts @@ -400,7 +400,8 @@ export const normalizePollingInputs = ( return pollingOptions; }; -const sizeOfComponentSet = (cs?: ComponentSet): number => cs?.size ?? 0; +/** yeah, there's a size property on CS. But we want the actual number of source components */ +const sizeOfComponentSet = (cs?: ComponentSet): number => cs?.getSourceComponents().toArray().length ?? 0; /** based on the size of the deploy (zip bytes for large deploys, component count for small ones), pick a reasonable polling frequency */ export const calculatePollingFrequency = (componentCount: number, zipSize?: number): number => { From 0997f4d3eb503d23324bab3df66b20029702a04b Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 15 Sep 2026 11:42:40 -0600 Subject: [PATCH 10/10] revert: remove getApiVersion() memoization sourceApiVersion and apiVersion are public mutable properties with no setter hooks, so a cached result can go stale if they are changed after the first call. The method is only called a handful of times per operation, so the fallback chain cost is negligible. --- src/collections/componentSet.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index da5ab92ae..091c93815 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -91,7 +91,6 @@ export class ComponentSet extends LazyCollection { public botVersionFilters?: Array<{ botName: string; versionFilter: 'all' | 'highest' | number }>; private logger: Logger; private readonly registry: RegistryAccess; - private resolvedApiVersion?: string; // all components stored here, regardless of what manifest they belong to private components = new DecodeableMap>(); @@ -754,8 +753,6 @@ export class ComponentSet extends LazyCollection { * @returns string The resolved API version to use in a manifest */ private async getApiVersion(): Promise { - if (this.resolvedApiVersion) return this.resolvedApiVersion; - let version = this.sourceApiVersion ?? this.apiVersion; if (!version) { @@ -784,7 +781,6 @@ export class ComponentSet extends LazyCollection { this.logger.warn(messages.getMessage('missingApiVersion')); } } - this.resolvedApiVersion = version; return version; } }