From 026e65e5eb2c63705c482a473495e48d2835a8fb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 14:07:05 +0000 Subject: [PATCH 1/7] perf(webpack-cli): allocate Levenshtein buffer lazily The 256 KB peq Uint32Array was allocated at module load, so every CLI invocation paid that cost even though distance() only runs on error paths ("did you mean" suggestions). Defer the allocation to the first distance() call. https://claude.ai/code/session_01TKJqMqs6zkot18iBUae52n --- .changeset/lazy-levenshtein-buffer.md | 5 +++++ packages/webpack-cli/src/levenshtein.ts | 12 ++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .changeset/lazy-levenshtein-buffer.md diff --git a/.changeset/lazy-levenshtein-buffer.md b/.changeset/lazy-levenshtein-buffer.md new file mode 100644 index 00000000000..63c6cc7d3e7 --- /dev/null +++ b/.changeset/lazy-levenshtein-buffer.md @@ -0,0 +1,5 @@ +--- +"webpack-cli": patch +--- + +Allocate the Levenshtein lookup buffer lazily so the 256 KB `Uint32Array` is only created when "did you mean" suggestions run (on error paths) rather than on every CLI invocation. diff --git a/packages/webpack-cli/src/levenshtein.ts b/packages/webpack-cli/src/levenshtein.ts index f719670bfa4..f2acd63c4a1 100644 --- a/packages/webpack-cli/src/levenshtein.ts +++ b/packages/webpack-cli/src/levenshtein.ts @@ -1,9 +1,11 @@ // Levenshtein distance via Myers' bit-parallel algorithm. // Inspired by fastest-levenshtein (MIT, https://github.com/ka-weihe/fastest-levenshtein). -const peq = new Uint32Array(0x10000); +// Allocated lazily on first `distance` call: the 256 KB buffer is only needed +// for "did you mean" suggestions, which run on error paths, not normal builds. +let peq: Uint32Array | undefined; -function myers32(a: string, b: string): number { +function myers32(a: string, b: string, peq: Uint32Array): number { const n = a.length; const m = b.length; const lst = 1 << (n - 1); @@ -46,7 +48,7 @@ function myers32(a: string, b: string): number { return sc; } -function myersX(longer: string, shorter: string): number { +function myersX(longer: string, shorter: string, peq: Uint32Array): number { const n = shorter.length; const m = longer.length; const mhc: number[] = []; @@ -161,5 +163,7 @@ export function distance(first: string, second: string): number { return a.length; } - return a.length <= 32 ? myers32(a, b) : myersX(a, b); + peq ??= new Uint32Array(0x10000); + + return a.length <= 32 ? myers32(a, b, peq) : myersX(a, b, peq); } From afcb5585490139ddd717683ca3baf32ad13d56fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 14:26:57 +0000 Subject: [PATCH 2/7] perf(webpack-cli): defer interpret import and inline Levenshtein helper Profiling `webpack build` showed CLI-owned code is a tiny fraction of startup; the cost is module loading. Two reductions there: - Default-config discovery now imports `interpret` only when no common-extension config (.js/.mjs/.cjs/.ts/.cts/.mts) is found, so the common `webpack.config.js` build never loads it. - The Levenshtein "did you mean" helper is inlined into webpack-cli as a private method, removing the separate module/import. The 256 KB buffer stays lazily allocated (error paths only). https://claude.ai/code/session_01TKJqMqs6zkot18iBUae52n --- .../defer-interpret-inline-levenshtein.md | 5 + packages/webpack-cli/src/levenshtein.ts | 169 ------------ packages/webpack-cli/src/webpack-cli.ts | 250 ++++++++++++++++-- test/api/levenshtein.test.js | 44 --- 4 files changed, 227 insertions(+), 241 deletions(-) create mode 100644 .changeset/defer-interpret-inline-levenshtein.md delete mode 100644 packages/webpack-cli/src/levenshtein.ts delete mode 100644 test/api/levenshtein.test.js diff --git a/.changeset/defer-interpret-inline-levenshtein.md b/.changeset/defer-interpret-inline-levenshtein.md new file mode 100644 index 00000000000..f7e2767d876 --- /dev/null +++ b/.changeset/defer-interpret-inline-levenshtein.md @@ -0,0 +1,5 @@ +--- +"webpack-cli": patch +--- + +Reduced CLI startup work for `webpack build`: the `interpret` package is now imported only when no common-extension config file (`.js`/`.mjs`/`.cjs`/`.ts`/`.cts`/`.mts`) exists, and the Levenshtein "did you mean" helper was inlined to drop a module import. diff --git a/packages/webpack-cli/src/levenshtein.ts b/packages/webpack-cli/src/levenshtein.ts deleted file mode 100644 index f2acd63c4a1..00000000000 --- a/packages/webpack-cli/src/levenshtein.ts +++ /dev/null @@ -1,169 +0,0 @@ -// Levenshtein distance via Myers' bit-parallel algorithm. -// Inspired by fastest-levenshtein (MIT, https://github.com/ka-weihe/fastest-levenshtein). - -// Allocated lazily on first `distance` call: the 256 KB buffer is only needed -// for "did you mean" suggestions, which run on error paths, not normal builds. -let peq: Uint32Array | undefined; - -function myers32(a: string, b: string, peq: Uint32Array): number { - const n = a.length; - const m = b.length; - const lst = 1 << (n - 1); - let pv = -1; - let mv = 0; - let sc = n; - let i = n; - - while (i--) { - peq[a.charCodeAt(i)] |= 1 << i; - } - - for (i = 0; i < m; i++) { - let eq = peq[b.charCodeAt(i)]; - const xv = eq | mv; - - eq |= ((eq & pv) + pv) ^ pv; - mv |= ~(eq | pv); - pv &= eq; - - if (mv & lst) { - sc++; - } - - if (pv & lst) { - sc--; - } - - mv = (mv << 1) | 1; - pv = (pv << 1) | ~(xv | mv); - mv &= xv; - } - - i = n; - - while (i--) { - peq[a.charCodeAt(i)] = 0; - } - - return sc; -} - -function myersX(longer: string, shorter: string, peq: Uint32Array): number { - const n = shorter.length; - const m = longer.length; - const mhc: number[] = []; - const phc: number[] = []; - const horizontalSize = Math.ceil(n / 32); - const verticalSize = Math.ceil(m / 32); - - for (let i = 0; i < horizontalSize; i++) { - phc[i] = -1; - mhc[i] = 0; - } - - let j = 0; - - for (; j < verticalSize - 1; j++) { - let mv = 0; - let pv = -1; - const start = j * 32; - const verticalLen = Math.min(32, m) + start; - - for (let k = start; k < verticalLen; k++) { - peq[longer.charCodeAt(k)] |= 1 << k; - } - - for (let i = 0; i < n; i++) { - const eq = peq[shorter.charCodeAt(i)]; - const pb = (phc[(i / 32) | 0] >>> i) & 1; - const mb = (mhc[(i / 32) | 0] >>> i) & 1; - const xv = eq | mv; - const xh = ((((eq | mb) & pv) + pv) ^ pv) | eq | mb; - let ph = mv | ~(xh | pv); - let mh = pv & xh; - - if ((ph >>> 31) ^ pb) { - phc[(i / 32) | 0] ^= 1 << i; - } - - if ((mh >>> 31) ^ mb) { - mhc[(i / 32) | 0] ^= 1 << i; - } - - ph = (ph << 1) | pb; - mh = (mh << 1) | mb; - pv = mh | ~(xv | ph); - mv = ph & xv; - } - - for (let k = start; k < verticalLen; k++) { - peq[longer.charCodeAt(k)] = 0; - } - } - - let mv = 0; - let pv = -1; - const start = j * 32; - const verticalLen = Math.min(32, m - start) + start; - - for (let k = start; k < verticalLen; k++) { - peq[longer.charCodeAt(k)] |= 1 << k; - } - - let score = m; - - for (let i = 0; i < n; i++) { - const eq = peq[shorter.charCodeAt(i)]; - const pb = (phc[(i / 32) | 0] >>> i) & 1; - const mb = (mhc[(i / 32) | 0] >>> i) & 1; - const xv = eq | mv; - const xh = ((((eq | mb) & pv) + pv) ^ pv) | eq | mb; - let ph = mv | ~(xh | pv); - let mh = pv & xh; - - score += (ph >>> (m - 1)) & 1; - score -= (mh >>> (m - 1)) & 1; - - if ((ph >>> 31) ^ pb) { - phc[(i / 32) | 0] ^= 1 << i; - } - - if ((mh >>> 31) ^ mb) { - mhc[(i / 32) | 0] ^= 1 << i; - } - - ph = (ph << 1) | pb; - mh = (mh << 1) | mb; - pv = mh | ~(xv | ph); - mv = ph & xv; - } - - for (let k = start; k < verticalLen; k++) { - peq[longer.charCodeAt(k)] = 0; - } - - return score; -} - -/** - * Returns the Levenshtein edit distance between two strings. - */ -export function distance(first: string, second: string): number { - let a = first; - let b = second; - - if (a.length < b.length) { - const tmp = b; - - b = a; - a = tmp; - } - - if (b.length === 0) { - return a.length; - } - - peq ??= new Uint32Array(0x10000); - - return a.length <= 32 ? myers32(a, b, peq) : myersX(a, b, peq); -} diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 7b3bca9345c..15e703be608 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -30,7 +30,6 @@ import { default as webpack, } from "webpack"; import { type Configuration as DevServerConfiguration } from "webpack-dev-server"; -import { distance } from "./levenshtein.js"; const WEBPACK_PACKAGE_IS_CUSTOM = Boolean(process.env.WEBPACK_PACKAGE); const WEBPACK_PACKAGE = WEBPACK_PACKAGE_IS_CUSTOM @@ -236,6 +235,154 @@ const DEFAULT_WEBPACK_PACKAGES: string[] = ["webpack", "loader"]; // Options that get a single-character alias derived from their name. const FLAGS_WITH_ALIAS = new Set(["devtool", "output-path", "target", "watch", "extends"]); +// Levenshtein distance via Myers' bit-parallel algorithm, used only for "did you +// mean" suggestions. Inspired by fastest-levenshtein (MIT, +// https://github.com/ka-weihe/fastest-levenshtein). +// +// The 256 KB buffer is allocated lazily on first use: suggestions only run on +// error paths, so a normal build never pays for it. +let levenshteinPeq: Uint32Array | undefined; + +function myers32(a: string, b: string, peq: Uint32Array): number { + const n = a.length; + const m = b.length; + const lst = 1 << (n - 1); + let pv = -1; + let mv = 0; + let sc = n; + let i = n; + + while (i--) { + peq[a.charCodeAt(i)] |= 1 << i; + } + + for (i = 0; i < m; i++) { + let eq = peq[b.charCodeAt(i)]; + const xv = eq | mv; + + eq |= ((eq & pv) + pv) ^ pv; + mv |= ~(eq | pv); + pv &= eq; + + if (mv & lst) { + sc++; + } + + if (pv & lst) { + sc--; + } + + mv = (mv << 1) | 1; + pv = (pv << 1) | ~(xv | mv); + mv &= xv; + } + + i = n; + + while (i--) { + peq[a.charCodeAt(i)] = 0; + } + + return sc; +} + +function myersX(longer: string, shorter: string, peq: Uint32Array): number { + const n = shorter.length; + const m = longer.length; + const mhc: number[] = []; + const phc: number[] = []; + const horizontalSize = Math.ceil(n / 32); + const verticalSize = Math.ceil(m / 32); + + for (let i = 0; i < horizontalSize; i++) { + phc[i] = -1; + mhc[i] = 0; + } + + let j = 0; + + for (; j < verticalSize - 1; j++) { + let mv = 0; + let pv = -1; + const start = j * 32; + const verticalLen = Math.min(32, m) + start; + + for (let k = start; k < verticalLen; k++) { + peq[longer.charCodeAt(k)] |= 1 << k; + } + + for (let i = 0; i < n; i++) { + const eq = peq[shorter.charCodeAt(i)]; + const pb = (phc[(i / 32) | 0] >>> i) & 1; + const mb = (mhc[(i / 32) | 0] >>> i) & 1; + const xv = eq | mv; + const xh = ((((eq | mb) & pv) + pv) ^ pv) | eq | mb; + let ph = mv | ~(xh | pv); + let mh = pv & xh; + + if ((ph >>> 31) ^ pb) { + phc[(i / 32) | 0] ^= 1 << i; + } + + if ((mh >>> 31) ^ mb) { + mhc[(i / 32) | 0] ^= 1 << i; + } + + ph = (ph << 1) | pb; + mh = (mh << 1) | mb; + pv = mh | ~(xv | ph); + mv = ph & xv; + } + + for (let k = start; k < verticalLen; k++) { + peq[longer.charCodeAt(k)] = 0; + } + } + + let mv = 0; + let pv = -1; + const start = j * 32; + const verticalLen = Math.min(32, m - start) + start; + + for (let k = start; k < verticalLen; k++) { + peq[longer.charCodeAt(k)] |= 1 << k; + } + + let score = m; + + for (let i = 0; i < n; i++) { + const eq = peq[shorter.charCodeAt(i)]; + const pb = (phc[(i / 32) | 0] >>> i) & 1; + const mb = (mhc[(i / 32) | 0] >>> i) & 1; + const xv = eq | mv; + const xh = ((((eq | mb) & pv) + pv) ^ pv) | eq | mb; + let ph = mv | ~(xh | pv); + let mh = pv & xh; + + score += (ph >>> (m - 1)) & 1; + score -= (mh >>> (m - 1)) & 1; + + if ((ph >>> 31) ^ pb) { + phc[(i / 32) | 0] ^= 1 << i; + } + + if ((mh >>> 31) ^ mb) { + mhc[(i / 32) | 0] ^= 1 << i; + } + + ph = (ph << 1) | pb; + mh = (mh << 1) | mb; + pv = mh | ~(xv | ph); + mv = ph & xv; + } + + for (let k = start; k < verticalLen; k++) { + peq[longer.charCodeAt(k)] = 0; + } + + return score; +} + class ConfigurationLoadingError extends Error { name = "ConfigurationLoadingError"; @@ -333,6 +480,27 @@ class WebpackCLI { return str.replaceAll(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); } + // Levenshtein edit distance between two strings, for "did you mean" suggestions. + #distance(first: string, second: string): number { + let a = first; + let b = second; + + if (a.length < b.length) { + const tmp = b; + + b = a; + a = tmp; + } + + if (b.length === 0) { + return a.length; + } + + levenshteinPeq ??= new Uint32Array(0x10000); + + return a.length <= 32 ? myers32(a, b, levenshteinPeq) : myersX(a, b, levenshteinPeq); + } + getLogger(): Logger { return { error: (val) => console.error(`[webpack-cli] ${this.colors.red(util.format(val))}`), @@ -2116,7 +2284,7 @@ class WebpackCLI { .map((option) => option.long?.slice(2) as string); for (const candidate of candidateNames) { - if (candidate && distance(name, candidate) < 3) { + if (candidate && this.#distance(name, candidate) < 3) { this.logger.error(`Did you mean '--${candidate}'?`); } } @@ -2257,7 +2425,7 @@ class WebpackCLI { this.logger.error(`Unknown command or entry '${operand}'`); const found = Object.values(this.#commands).find( - (commandOptions) => distance(operand, commandOptions.rawName) < 3, + (commandOptions) => this.#distance(operand, commandOptions.rawName) < 3, ); if (found) { @@ -2286,25 +2454,23 @@ class WebpackCLI { // Finds the highest-priority default config file by reading each candidate directory once (case-insensitively) and confirming with `access`, instead of probing every `` combination separately. async #findDefaultConfigFile(): Promise { - const interpret = await import("interpret"); - // Prioritize popular extensions first to avoid unnecessary fs calls - const seenExtensions = new Set(); - const orderedExtensions: string[] = []; - - for (const ext of [ - ".js", - ".mjs", - ".cjs", - ".ts", - ".cts", - ".mts", - ...Object.keys(interpret.extensions), - ]) { - if (!seenExtensions.has(ext)) { - seenExtensions.add(ext); - orderedExtensions.push(ext); + // Popular extensions, tried first. The common case (e.g. `webpack.config.js`) + // matches here, so `interpret` is never loaded — see `getExoticExtensions`. + const commonExtensions = [".js", ".mjs", ".cjs", ".ts", ".cts", ".mts"]; + + // `interpret`'s extra extensions (e.g. `.coffee`) are only needed when no + // common-extension config exists, so defer importing it until then. + let exoticExtensions: string[] | undefined; + const getExoticExtensions = async () => { + if (typeof exoticExtensions === "undefined") { + const interpret = await import("interpret"); + const common = new Set(commonExtensions); + + exoticExtensions = Object.keys(interpret.extensions).filter((ext) => !common.has(ext)); } - } + + return exoticExtensions; + }; const directoryEntriesCache = new Map | null>(); const readDirectoryEntries = async (directory: string) => { @@ -2325,13 +2491,13 @@ class WebpackCLI { return entries; }; - // Order defines the priority, in decreasing order - for (const filename of DEFAULT_CONFIGURATION_FILES) { - const resolvedBase = path.resolve(filename); - const entries = await readDirectoryEntries(path.dirname(resolvedBase)); - const basename = path.basename(resolvedBase); - - for (const ext of orderedExtensions) { + const findInExtensions = async ( + resolvedBase: string, + basename: string, + entries: Set | null, + extensions: string[], + ): Promise => { + for (const ext of extensions) { // Skip candidates absent from the listing, but when the directory can't be listed (`entries` is `null`) probe every candidate directly. if (entries && !entries.has((basename + ext).toLowerCase())) { continue; @@ -2348,6 +2514,34 @@ class WebpackCLI { // Listed but not accessible, keep looking } } + + return undefined; + }; + + // Order defines the priority, in decreasing order. Within each filename, + // common extensions take priority over the exotic ones (matching the + // previous combined ordering). + for (const filename of DEFAULT_CONFIGURATION_FILES) { + const resolvedBase = path.resolve(filename); + const entries = await readDirectoryEntries(path.dirname(resolvedBase)); + const basename = path.basename(resolvedBase); + + const common = await findInExtensions(resolvedBase, basename, entries, commonExtensions); + + if (common) { + return common; + } + + const exotic = await findInExtensions( + resolvedBase, + basename, + entries, + await getExoticExtensions(), + ); + + if (exotic) { + return exotic; + } } return undefined; diff --git a/test/api/levenshtein.test.js b/test/api/levenshtein.test.js deleted file mode 100644 index 68ed2897488..00000000000 --- a/test/api/levenshtein.test.js +++ /dev/null @@ -1,44 +0,0 @@ -const { distance } = require("../../packages/webpack-cli/lib/levenshtein"); - -describe("distance", () => { - it("should return 0 for equal strings", () => { - expect(distance("", "")).toBe(0); - expect(distance("webpack", "webpack")).toBe(0); - }); - - it("should return the length of the other string when one is empty", () => { - expect(distance("", "abc")).toBe(3); - expect(distance("abc", "")).toBe(3); - }); - - it("should not depend on argument order", () => { - expect(distance("kitten", "sitting")).toBe(distance("sitting", "kitten")); - }); - - it("should count single edits", () => { - expect(distance("server", "serve")).toBe(1); - expect(distance("test", "tests")).toBe(1); - expect(distance("cat", "car")).toBe(1); - }); - - it("should compute classic distances", () => { - expect(distance("kitten", "sitting")).toBe(3); - expect(distance("flying", "sailing")).toBe(4); - }); - - it("should handle strings longer than 32 characters", () => { - const a = "a".repeat(40); - const b = `${"a".repeat(39)}b`; - - expect(distance(a, b)).toBe(1); - expect(distance("a".repeat(40), "b".repeat(40))).toBe(40); - }); - - it("should handle a long string against a much shorter one", () => { - const long = "abcdefghijklmnopqrstuvwxyz0123456789ABCD"; - - expect(long).toHaveLength(40); - expect(distance(long, "abcde")).toBe(35); - expect(distance("abcde", long)).toBe(35); - }); -}); From 1cbf6b69d170f328dea4e75311b3bd18aac1da85 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 14:35:53 +0000 Subject: [PATCH 3/7] test(webpack-cli): restore Levenshtein unit tests against inlined helper Migrate the previously removed test/api/levenshtein.test.js to exercise the inlined implementation. The helper is now a `private static distance` method (TypeScript `private` is erased at runtime), so the unit tests call it directly without re-introducing a separate module or import. https://claude.ai/code/session_01TKJqMqs6zkot18iBUae52n --- packages/webpack-cli/src/webpack-cli.ts | 6 +-- test/api/levenshtein.test.js | 49 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 test/api/levenshtein.test.js diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 15e703be608..bea1c4862f8 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -481,7 +481,7 @@ class WebpackCLI { } // Levenshtein edit distance between two strings, for "did you mean" suggestions. - #distance(first: string, second: string): number { + private static distance(first: string, second: string): number { let a = first; let b = second; @@ -2284,7 +2284,7 @@ class WebpackCLI { .map((option) => option.long?.slice(2) as string); for (const candidate of candidateNames) { - if (candidate && this.#distance(name, candidate) < 3) { + if (candidate && WebpackCLI.distance(name, candidate) < 3) { this.logger.error(`Did you mean '--${candidate}'?`); } } @@ -2425,7 +2425,7 @@ class WebpackCLI { this.logger.error(`Unknown command or entry '${operand}'`); const found = Object.values(this.#commands).find( - (commandOptions) => this.#distance(operand, commandOptions.rawName) < 3, + (commandOptions) => WebpackCLI.distance(operand, commandOptions.rawName) < 3, ); if (found) { diff --git a/test/api/levenshtein.test.js b/test/api/levenshtein.test.js new file mode 100644 index 00000000000..d272602b14f --- /dev/null +++ b/test/api/levenshtein.test.js @@ -0,0 +1,49 @@ +const WebpackCLI = require("../../packages/webpack-cli/lib/webpack-cli").default; + +// `distance` is a private static method (used for "did you mean" suggestions); +// the TypeScript `private` modifier is erased at runtime, so the migrated unit +// tests can still exercise the algorithm directly. +const distance = (first, second) => WebpackCLI.distance(first, second); + +describe("distance", () => { + it("should return 0 for equal strings", () => { + expect(distance("", "")).toBe(0); + expect(distance("webpack", "webpack")).toBe(0); + }); + + it("should return the length of the other string when one is empty", () => { + expect(distance("", "abc")).toBe(3); + expect(distance("abc", "")).toBe(3); + }); + + it("should not depend on argument order", () => { + expect(distance("kitten", "sitting")).toBe(distance("sitting", "kitten")); + }); + + it("should count single edits", () => { + expect(distance("server", "serve")).toBe(1); + expect(distance("test", "tests")).toBe(1); + expect(distance("cat", "car")).toBe(1); + }); + + it("should compute classic distances", () => { + expect(distance("kitten", "sitting")).toBe(3); + expect(distance("flying", "sailing")).toBe(4); + }); + + it("should handle strings longer than 32 characters", () => { + const a = "a".repeat(40); + const b = `${"a".repeat(39)}b`; + + expect(distance(a, b)).toBe(1); + expect(distance("a".repeat(40), "b".repeat(40))).toBe(40); + }); + + it("should handle a long string against a much shorter one", () => { + const long = "abcdefghijklmnopqrstuvwxyz0123456789ABCD"; + + expect(long).toHaveLength(40); + expect(distance(long, "abcde")).toBe(35); + expect(distance("abcde", long)).toBe(35); + }); +}); From 6e1e3fc3424ea2df962766b5de8551dde0da028a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 15:03:23 +0000 Subject: [PATCH 4/7] perf(webpack-cli): skip schema-to-arguments walk when no flags are passed A plain `webpack build` (no option flags) previously still ran webpack's ~28ms `getArguments` schema walk and built the full ~864-entry option list, then registered nothing. Now: - makeCommand skips building/registering options entirely when argv has no option flags (nothing to register, and no unknown-option suggestions are possible without flags). - loadConfig computes the argument metadata lazily, skipping it when only internal keys (webpack/argv/isWatchingLikeCommand) are present. Net: a plain build makes 0 getArguments calls (was 1), ~14-18ms faster startup and ~2.5MB less heap. Builds with flags or entry operands are unchanged (getArguments still runs once, as before). https://claude.ai/code/session_01TKJqMqs6zkot18iBUae52n --- .changeset/skip-getarguments-no-flags.md | 5 ++ packages/webpack-cli/src/webpack-cli.ts | 74 ++++++++++++++---------- 2 files changed, 48 insertions(+), 31 deletions(-) create mode 100644 .changeset/skip-getarguments-no-flags.md diff --git a/.changeset/skip-getarguments-no-flags.md b/.changeset/skip-getarguments-no-flags.md new file mode 100644 index 00000000000..0574911e44b --- /dev/null +++ b/.changeset/skip-getarguments-no-flags.md @@ -0,0 +1,5 @@ +--- +"webpack-cli": patch +--- + +Skip the webpack schema-to-arguments walk on a plain `webpack build` (and other no-flag invocations). When no option flags are present, the CLI no longer builds the full option list or calls `getArguments`, reducing startup time and peak memory for the most common invocation. diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index bea1c4862f8..c5f3e2e6bd0 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -235,6 +235,10 @@ const DEFAULT_WEBPACK_PACKAGES: string[] = ["webpack", "loader"]; // Options that get a single-character alias derived from their name. const FLAGS_WITH_ALIAS = new Set(["devtool", "output-path", "target", "watch", "extends"]); +// Keys the CLI sets on the parsed options itself (never webpack arguments), so +// they don't need to be forwarded to webpack's `processArguments`. +const INTERNAL_OPTION_KEYS = new Set(["webpack", "argv", "isWatchingLikeCommand"]); + // Levenshtein distance via Myers' bit-parallel algorithm, used only for "did you // mean" suggestions. Inspired by fastest-levenshtein (MIT, // https://github.com/ka-weihe/fastest-levenshtein). @@ -807,43 +811,49 @@ class WebpackCLI { } if (options.options) { - let commandOptions: CommandOption[]; + // Register every option for help, otherwise only the ones present in argv. + const neededOptions = forHelp ? undefined : this.#neededOptionNames(); - if ( - forHelp && - !allDependenciesInstalled && - options.dependencies && - options.dependencies.length > 0 - ) { - commandOptions = []; - } else if (typeof options.options === "function") { - commandOptions = await options.options(command); - } else { - commandOptions = options.options; - } + // With no option flags in argv (e.g. a plain `webpack build`), nothing + // needs to be registered and no unknown-option suggestions are possible, + // so skip building the (large) option list entirely. This avoids the + // schema-to-arguments walk on the most common invocation. + if (!neededOptions || neededOptions.size > 0) { + let commandOptions: CommandOption[]; + + if ( + forHelp && + !allDependenciesInstalled && + options.dependencies && + options.dependencies.length > 0 + ) { + commandOptions = []; + } else if (typeof options.options === "function") { + commandOptions = await options.options(command); + } else { + commandOptions = options.options; + } - // Keep all option names (including `no-` negated forms) for "did you mean" suggestions, since not every option is registered below. - const allOptionNames: string[] = []; + // Keep all option names (including `no-` negated forms) for "did you mean" suggestions, since not every option is registered below. + const allOptionNames: string[] = []; - for (const option of commandOptions) { - allOptionNames.push(option.name); + for (const option of commandOptions) { + allOptionNames.push(option.name); - if (this.#optionSupportsNegation(option)) { - allOptionNames.push(`no-${option.name}`); + if (this.#optionSupportsNegation(option)) { + allOptionNames.push(`no-${option.name}`); + } } - } - (command as Command & { allOptionNames?: string[] }).allOptionNames = allOptionNames; + (command as Command & { allOptionNames?: string[] }).allOptionNames = allOptionNames; - // Register every option for help, otherwise only the ones present in argv. - const neededOptions = forHelp ? undefined : this.#neededOptionNames(); + for (const option of commandOptions) { + if (neededOptions && !this.#isOptionNeeded(option, neededOptions)) { + continue; + } - for (const option of commandOptions) { - if (neededOptions && !this.#isOptionNeeded(option, neededOptions)) { - continue; + this.makeOption(command, option); } - - this.makeOption(command, option); } } @@ -2899,7 +2909,9 @@ class WebpackCLI { // `getArguments()` already returns a name-keyed map of exactly the argument // metadata `processArguments` consumes, so use it directly (cached) instead // of rebuilding a `schemaToOptions` array and a lookup map on every run. - const builtInArgs = this.#getArguments(options.webpack, undefined); + // Computed lazily: a plain `webpack build` only has internal option keys, so + // it skips the schema-to-arguments walk entirely. + let builtInArgs: ReturnType<(typeof webpack)["cli"]["getArguments"]> | undefined; const internalBuildConfig = (configuration: Configuration) => { const originalWatchValue = configuration.watch; @@ -2908,10 +2920,10 @@ class WebpackCLI { const values: ProcessedArguments = {}; for (const name of Object.keys(options)) { - if (name === "argv") continue; + if (INTERNAL_OPTION_KEYS.has(name)) continue; const kebabName = this.toKebabCase(name); - const arg = builtInArgs[kebabName]; + const arg = (builtInArgs ??= this.#getArguments(options.webpack, undefined))[kebabName]; if (arg) { args[name] = arg; From c3ca15058272210d0c153694813d026710b0f8ce Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 15:34:39 +0000 Subject: [PATCH 5/7] perf(webpack-cli): defer CLI require until after local-install check bin/cli.js required both `import-local` and the full CLI implementation up front. Reorder so: - a run delegated to a local install returns without loading the outer installation's CLI lib (+commander), and - WEBPACK_CLI_SKIP_IMPORT_LOCAL short-circuits before requiring `import-local` at all (10 fewer modules loaded on that path). The typical local invocation is unchanged. Behavior is preserved (import-local still runs by default). https://claude.ai/code/session_01TKJqMqs6zkot18iBUae52n --- .changeset/defer-cli-require-in-bin.md | 5 +++++ packages/webpack-cli/bin/cli.js | 19 +++++++++---------- 2 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 .changeset/defer-cli-require-in-bin.md diff --git a/.changeset/defer-cli-require-in-bin.md b/.changeset/defer-cli-require-in-bin.md new file mode 100644 index 00000000000..89c9ce43c1f --- /dev/null +++ b/.changeset/defer-cli-require-in-bin.md @@ -0,0 +1,5 @@ +--- +"webpack-cli": patch +--- + +Defer requiring the CLI implementation until after the local-installation check in the `webpack` bin. A run delegated to a local `webpack-cli` no longer loads the outer installation's modules, and `WEBPACK_CLI_SKIP_IMPORT_LOCAL` now also skips loading `import-local` itself. diff --git a/packages/webpack-cli/bin/cli.js b/packages/webpack-cli/bin/cli.js index 1e98935cefe..b3a6ca5e11a 100755 --- a/packages/webpack-cli/bin/cli.js +++ b/packages/webpack-cli/bin/cli.js @@ -2,7 +2,15 @@ "use strict"; -const importLocal = require("import-local"); +// Prefer the local installation of `webpack-cli` when one exists. Run this +// before requiring the (heavier) CLI implementation: a delegated run then never +// loads it, and `WEBPACK_CLI_SKIP_IMPORT_LOCAL` skips loading `import-local` too. +if (!process.env.WEBPACK_CLI_SKIP_IMPORT_LOCAL && require("import-local")(__filename)) { + return; +} + +process.title = "webpack"; + const WebpackCLI = require("../lib/webpack-cli").default; const runCLI = async (args) => { @@ -16,14 +24,5 @@ const runCLI = async (args) => { } }; -if ( - !process.env.WEBPACK_CLI_SKIP_IMPORT_LOCAL && // Prefer the local installation of `webpack-cli` - importLocal(__filename) -) { - return; -} - -process.title = "webpack"; - // eslint-disable-next-line unicorn/prefer-top-level-await runCLI(process.argv); From bb962d5ad38a627e93e57fe5c84f55d063a511ea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 15:51:53 +0000 Subject: [PATCH 6/7] chore: consolidate performance changesets into one entry https://claude.ai/code/session_01TKJqMqs6zkot18iBUae52n --- .changeset/cli-performance.md | 5 +++++ .changeset/defer-cli-require-in-bin.md | 5 ----- .changeset/defer-interpret-inline-levenshtein.md | 5 ----- .changeset/lazy-levenshtein-buffer.md | 5 ----- .changeset/skip-getarguments-no-flags.md | 5 ----- 5 files changed, 5 insertions(+), 20 deletions(-) create mode 100644 .changeset/cli-performance.md delete mode 100644 .changeset/defer-cli-require-in-bin.md delete mode 100644 .changeset/defer-interpret-inline-levenshtein.md delete mode 100644 .changeset/lazy-levenshtein-buffer.md delete mode 100644 .changeset/skip-getarguments-no-flags.md diff --git a/.changeset/cli-performance.md b/.changeset/cli-performance.md new file mode 100644 index 00000000000..14838e32261 --- /dev/null +++ b/.changeset/cli-performance.md @@ -0,0 +1,5 @@ +--- +"webpack-cli": patch +--- + +Improved CLI startup performance and reduced memory usage. diff --git a/.changeset/defer-cli-require-in-bin.md b/.changeset/defer-cli-require-in-bin.md deleted file mode 100644 index 89c9ce43c1f..00000000000 --- a/.changeset/defer-cli-require-in-bin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"webpack-cli": patch ---- - -Defer requiring the CLI implementation until after the local-installation check in the `webpack` bin. A run delegated to a local `webpack-cli` no longer loads the outer installation's modules, and `WEBPACK_CLI_SKIP_IMPORT_LOCAL` now also skips loading `import-local` itself. diff --git a/.changeset/defer-interpret-inline-levenshtein.md b/.changeset/defer-interpret-inline-levenshtein.md deleted file mode 100644 index f7e2767d876..00000000000 --- a/.changeset/defer-interpret-inline-levenshtein.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"webpack-cli": patch ---- - -Reduced CLI startup work for `webpack build`: the `interpret` package is now imported only when no common-extension config file (`.js`/`.mjs`/`.cjs`/`.ts`/`.cts`/`.mts`) exists, and the Levenshtein "did you mean" helper was inlined to drop a module import. diff --git a/.changeset/lazy-levenshtein-buffer.md b/.changeset/lazy-levenshtein-buffer.md deleted file mode 100644 index 63c6cc7d3e7..00000000000 --- a/.changeset/lazy-levenshtein-buffer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"webpack-cli": patch ---- - -Allocate the Levenshtein lookup buffer lazily so the 256 KB `Uint32Array` is only created when "did you mean" suggestions run (on error paths) rather than on every CLI invocation. diff --git a/.changeset/skip-getarguments-no-flags.md b/.changeset/skip-getarguments-no-flags.md deleted file mode 100644 index 0574911e44b..00000000000 --- a/.changeset/skip-getarguments-no-flags.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"webpack-cli": patch ---- - -Skip the webpack schema-to-arguments walk on a plain `webpack build` (and other no-flag invocations). When no option flags are present, the CLI no longer builds the full option list or calls `getArguments`, reducing startup time and peak memory for the most common invocation. From 7c20d698d31c07c1e204bf0074dfb122c522324b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 16:06:10 +0000 Subject: [PATCH 7/7] refactor(webpack-cli): use #distance private method for Levenshtein Replace the TypeScript `private static distance` with a `#distance` private method that delegates to a module-scoped `distance` function. The function is exported so the unit tests can exercise the algorithm directly. https://claude.ai/code/session_01TKJqMqs6zkot18iBUae52n --- packages/webpack-cli/src/webpack-cli.ts | 47 ++++++++++++++----------- test/api/levenshtein.test.js | 9 ++--- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index c5f3e2e6bd0..2e9962eac85 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -387,6 +387,29 @@ function myersX(longer: string, shorter: string, peq: Uint32Array): number { return score; } +// Levenshtein edit distance between two strings, used for "did you mean" +// suggestions. Exported only so it can be unit-tested directly; the CLI uses it +// through the private `WebpackCLI.#distance`. +export function distance(first: string, second: string): number { + let a = first; + let b = second; + + if (a.length < b.length) { + const tmp = b; + + b = a; + a = tmp; + } + + if (b.length === 0) { + return a.length; + } + + levenshteinPeq ??= new Uint32Array(0x10000); + + return a.length <= 32 ? myers32(a, b, levenshteinPeq) : myersX(a, b, levenshteinPeq); +} + class ConfigurationLoadingError extends Error { name = "ConfigurationLoadingError"; @@ -485,24 +508,8 @@ class WebpackCLI { } // Levenshtein edit distance between two strings, for "did you mean" suggestions. - private static distance(first: string, second: string): number { - let a = first; - let b = second; - - if (a.length < b.length) { - const tmp = b; - - b = a; - a = tmp; - } - - if (b.length === 0) { - return a.length; - } - - levenshteinPeq ??= new Uint32Array(0x10000); - - return a.length <= 32 ? myers32(a, b, levenshteinPeq) : myersX(a, b, levenshteinPeq); + static #distance(first: string, second: string): number { + return distance(first, second); } getLogger(): Logger { @@ -2294,7 +2301,7 @@ class WebpackCLI { .map((option) => option.long?.slice(2) as string); for (const candidate of candidateNames) { - if (candidate && WebpackCLI.distance(name, candidate) < 3) { + if (candidate && WebpackCLI.#distance(name, candidate) < 3) { this.logger.error(`Did you mean '--${candidate}'?`); } } @@ -2435,7 +2442,7 @@ class WebpackCLI { this.logger.error(`Unknown command or entry '${operand}'`); const found = Object.values(this.#commands).find( - (commandOptions) => WebpackCLI.distance(operand, commandOptions.rawName) < 3, + (commandOptions) => WebpackCLI.#distance(operand, commandOptions.rawName) < 3, ); if (found) { diff --git a/test/api/levenshtein.test.js b/test/api/levenshtein.test.js index d272602b14f..0a5251ded03 100644 --- a/test/api/levenshtein.test.js +++ b/test/api/levenshtein.test.js @@ -1,9 +1,6 @@ -const WebpackCLI = require("../../packages/webpack-cli/lib/webpack-cli").default; - -// `distance` is a private static method (used for "did you mean" suggestions); -// the TypeScript `private` modifier is erased at runtime, so the migrated unit -// tests can still exercise the algorithm directly. -const distance = (first, second) => WebpackCLI.distance(first, second); +// The CLI uses this through the private `WebpackCLI.#distance`; it is exported +// from the module so these unit tests can exercise the algorithm directly. +const { distance } = require("../../packages/webpack-cli/lib/webpack-cli"); describe("distance", () => { it("should return 0 for equal strings", () => {