Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
perf(webpack-cli): register only the options present in argv
Setting up a command registered all ~850 webpack/dev-server options on
commander on every run, which dominated CLI setup time (~28ms) and retained
~1.4MB of Option objects. Register only the options actually present in the
argument tokens instead; unrecognized flags still error, and "did you mean"
suggestions use the full option-name list stashed on the command. Help still
registers every option so it can list them all.

Cuts command-setup time roughly in half and CLI overhead by ~35% for a
typical build, and drops retained heap by ~1.4MB.

https://claude.ai/code/session_01PEtzv6Xqv2yXQaQsZaeoSF
  • Loading branch information
claude committed May 26, 2026
commit 50a9e7c2593beb6a1a42068eb94f3dede5a681b3
2 changes: 1 addition & 1 deletion .changeset/fast-pumas-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"webpack-cli": patch
---

Cache CLI argument metadata built from the webpack/dev-server schema and apply CLI options using the cached name-keyed map directly, avoiding a redundant schema walk and the rebuild of a large options array and lookup map on every run. Default-config discovery now reads each candidate directory once instead of probing every `<name><ext>` combination with a separate `fs.access` call (up to ~100 sequential syscalls when no config file exists). Colors are also created lazily, so commands that don't need webpack (such as `version` and `info`) no longer load it. The cached argument metadata (~1MB per schema) is held via `WeakRef` so the garbage collector can reclaim it once command setup is done, which matters for long-running `serve`/`watch`. The `serve` command no longer retains the full option arrays (~900KB) in its context for the whole session, deriving the lookups it needs from the cached argument metadata instead. This reduces per-invocation CPU work, syscalls, and memory usage.
Cache CLI argument metadata built from the webpack/dev-server schema and apply CLI options using the cached name-keyed map directly, avoiding a redundant schema walk and the rebuild of a large options array and lookup map on every run. Default-config discovery now reads each candidate directory once instead of probing every `<name><ext>` combination with a separate `fs.access` call (up to ~100 sequential syscalls when no config file exists). Colors are also created lazily, so commands that don't need webpack (such as `version` and `info`) no longer load it. The cached argument metadata (~1MB per schema) is held via `WeakRef` so the garbage collector can reclaim it once command setup is done, which matters for long-running `serve`/`watch`. The `serve` command no longer retains the full option arrays (~900KB) in its context for the whole session, deriving the lookups it needs from the cached argument metadata instead. Command setup now registers only the options actually present in the arguments instead of all ~850, which roughly halves command-setup time and avoids retaining ~1.4MB of option objects per run (help still lists every option). This reduces per-invocation CPU work, syscalls, and memory usage.
95 changes: 89 additions & 6 deletions packages/webpack-cli/src/webpack-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,10 @@ class WebpackCLI {

#isColorSupportChanged: boolean | undefined;

// Flag tokens of the current invocation, used to register only the options
// actually present (instead of all ~850) when setting up a command.
#argvForParsing: readonly string[] | undefined;

program: Command;

constructor() {
Expand Down Expand Up @@ -650,7 +654,22 @@ class WebpackCLI {
commandOptions = options.options;
}

// Keep all option names for "did you mean" suggestions on unknown options,
// since not every option is registered on commander below.
(command as Command & { allOptionNames?: string[] }).allOptionNames = commandOptions.map(
(option) => option.name,
);

// For help we register every option (help lists them all). Otherwise we
// register only the options actually present in argv, avoiding the cost of
// building ~850 commander Options per run. Unrecognized flags still error.
const neededOptions = forHelp ? undefined : this.#neededOptionNames();

for (const option of commandOptions) {
if (neededOptions && !this.#isOptionNeeded(option, neededOptions)) {
continue;
}

this.makeOption(command, option);
}
}
Expand All @@ -660,6 +679,61 @@ class WebpackCLI {
return command;
}

#neededOptionNames(): Set<string> | undefined {
const argv = this.#argvForParsing;

if (!argv) {
return undefined;
}

const names = new Set<string>();

for (const token of argv) {
// Must start with `-` to name an option.
if (token.length < 2 || token.charCodeAt(0) !== 45) {
continue;
}

if (token.charCodeAt(1) === 45) {
// Long option: `--name` or `--name=value`.
let name = token.slice(2);
const equalsIndex = name.indexOf("=");

if (equalsIndex !== -1) {
name = name.slice(0, equalsIndex);
}

if (!name) {
continue;
}

names.add(name);

// `--no-x` must register the `x` option (which provides the negation).
if (name.startsWith("no-")) {
names.add(name.slice(3));
}
} else {
// Short option: the alias is the first character; the rest (if any) is
// an attached value, e.g. `-d<value>` means `-d <value>`.
names.add(token[1]);
}
}

return names;
}

#isOptionNeeded(option: CommandOption, neededOptions: Set<string>): boolean {
if (neededOptions.has(option.name)) {
return true;
}

// `makeOption` derives a single-character alias for these from the name.
const alias = option.alias ?? (FLAGS_WITH_ALIAS.has(option.name) ? option.name[0] : undefined);

return typeof alias === "string" && neededOptions.has(alias);
}

makeOption(command: Command, option: CommandOption) {
type MainOption = Pick<
CommandOption,
Expand Down Expand Up @@ -2020,12 +2094,16 @@ class WebpackCLI {
process.exit(2);
}

for (const option of command.options) {
if (
!(option as Option & { internal?: boolean }).internal &&
distance(name, option.long?.slice(2) as string) < 3
) {
this.logger.error(`Did you mean '--${option.name()}'?`);
const { allOptionNames } = command as Command & { allOptionNames?: string[] };
const candidateNames =
allOptionNames ??
command.options
.filter((option) => !(option as Option & { internal?: boolean }).internal)
.map((option) => option.long?.slice(2) as string);

for (const candidate of candidateNames) {
if (candidate && distance(name, candidate) < 3) {
this.logger.error(`Did you mean '--${candidate}'?`);
}
}
}
Expand Down Expand Up @@ -2073,6 +2151,11 @@ class WebpackCLI {
this.program.allowExcessArguments(true);
this.program.action(async (options) => {
const { operands, unknown } = this.program.parseOptions(this.program.args);

// Remember the flag tokens so command setup only registers options that
// are actually used (see `#neededOptionNames`).
this.#argvForParsing = unknown;

const defaultCommandNameToRun = this.#commands.build.rawName;
const hasOperand = typeof operands[0] !== "undefined";
const operand = hasOperand ? operands[0] : defaultCommandNameToRun;
Expand Down
Loading