Skip to content

Skip phantom entry targets in subgraph when enablePhantomTargetOptimization is enabled#1102

Open
christiango wants to merge 7 commits intomainfrom
christiango/more-phantom-deps-trimming
Open

Skip phantom entry targets in subgraph when enablePhantomTargetOptimization is enabled#1102
christiango wants to merge 7 commits intomainfrom
christiango/more-phantom-deps-trimming

Conversation

@christiango
Copy link
Copy Markdown
Member

@christiango christiango commented Apr 8, 2026

Summary

Extends the existing phantom target optimization to also filter out phantom entry targets during subgraph construction. Previously, enablePhantomTargetOptimization only skipped phantom targets during ^/^^ dependency expansion in expandDepSpecs. This left a gap: when running a task that only some packages define or that explicitly opts out via shouldRun, every package still got added as a subgraph entry point, pulling in their same-package dependencies unnecessarily.

Problem

When running a task like test:commandScript that only a single package defines, lage creates entry targets for all packages in scope. Each phantom entry target's same-package dependency on bundle pulls that expensive bundle task into the subgraph. In a ~200 package monorepo, this inflated the target graph from ~91 targets to ~366 — including multiple 6GB+ bundle tasks that had no work to do.

The same problem applies to non-npmScript targets (e.g. worker type) that define a shouldRun callback returning false — they still got added as entry points, dragging in their dependency trees.

Fix

In WorkspaceTargetGraphBuilder.build(), when enablePhantomTargetOptimization is true, skip adding a package to subGraphEntries if:

  1. The target's shouldRun resolved to false (covers worker, noop, and any target type with a shouldRun callback), or
  2. The target is an npmScript type and the package doesn't define the task in its package.json scripts (existing phantom target check, now also applied at entry selection)

To support this, shouldRun resolution is moved earlier in build() — before entry filtering instead of after subgraph construction. To minimize overhead:

  • Only entry-candidate targets (scope × tasks) are resolved, not all targets
  • Targets without a shouldRun callback are set synchronously to true with no promise overhead
  • Async callbacks are batched through pLimit(8)

All downstream consumers already handle shouldRun: undefined correctly via ?? true or === false checks, so non-entry targets that don't get resolved are unaffected.

Risk

  • Gated behind enablePhantomTargetOptimization (defaults to false), so no behavior change for existing consumers.
  • expandDepSpecs and isPhantomTarget are completely untouched — dependency expansion logic is unchanged.
  • Only affects entry target selection in WorkspaceTargetGraphBuilder.build().

Testing

  • Two new unit tests: one verifying worker targets are excluded when shouldRun returns false, one verifying worker targets without shouldRun are still included.
  • Validated in a large monorepo: target count for a single-package task dropped from 366 → 91, eliminating all phantom bundle targets.

@christiango christiango requested a review from ecraig12345 April 8, 2026 12:44
@christiango
Copy link
Copy Markdown
Member Author

I need to test something, going to move to draft

@christiango christiango marked this pull request as draft April 8, 2026 16:47
// don't define the task script. Without this, phantom entry targets pull their
// same-package dependencies into the subgraph unnecessarily.
if (this.options.enablePhantomTargetOptimization) {
const pkg = this.options.packageInfos[packageName];
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is too aggressive. We need to make sure it's an npm script when we do this

// Only npmScript targets can be phantom — other types (worker, noop, etc.)
// are skipped only when shouldRun is explicitly false.
const targetId = getTargetId(packageName, task);
if (this.options.enablePhantomTargetOptimization) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to simplify this

@christiango christiango marked this pull request as ready for review April 8, 2026 23:15
}
} else {
// For non-npmScript targets (e.g. worker), skip if shouldRun resolves to false
const config = this.targetConfigMap.get(targetId);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For worker runners, this has the side effect of resolving and loading the script module, which could be expensive (though I guess it would be cached after the first load). So I wonder if it's better to just ignore non-npmScript targets for the phantom target check here, similar to the other check in expandDepSpecs?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So for our case we actually need it to filter out workers as well. I couldn't find any evidence of this triggering the worker script module, the intent is only to look at the worker config's should run (confusing that both places have a should run). I believe the worker still isn't loaded until the runner phase

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My tests show this as well, if I comment out the target config should run here, the test fails and the task is in the build graph

builder.addTargetConfig("test:special", {
  type: "worker",
  dependsOn: ["bundle"],
  //shouldRun: async (target) => Promise.resolve(target.packageName === "tool-app"),
});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a further sanity check I did the following, I made the worker script in the repo throw on the first line and I added a console log statement before the this.hasRoottarget check on line 255. Here was the output, showing the worker is not loaded with this change:

This is when we finished processing the targets
Error: This is when the worker was loaded

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where it loads the script in shouldRun:

public async shouldRun(target: Target): Promise<boolean> {
const scriptModule = await this.getScriptModule(target);
if (typeof scriptModule.shouldRun === "function") {
return (await scriptModule.shouldRun(target)) && (target.shouldRun ?? true);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not the one I am checking , confusingly there’s also one in the config as well that gets read before the script. The test that skips when should run is false is implementing it at the config level, not the runner level

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the test it’s defined in parallel to depends on in the config

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants