Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 8 additions & 0 deletions .changeset/lovely-pens-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@atlaspack/types-internal': minor
'@atlaspack/feature-flags': minor
'@atlaspack/packager-js': minor
'@atlaspack/core': minor
---

Introduce a new `getReferencedAssets(bundle)` method to the BundleGraph to pre-compute referenced assets, this is used by the scope hoisting packager behind a new `precomputeReferencedAssets` feature flag.
152 changes: 152 additions & 0 deletions packages/core/core/src/BundleGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1435,6 +1435,158 @@ export default class BundleGraph {
});
}

// New method: Fast checks only (no caching of results)
isAssetReferencedFastCheck(bundle: Bundle, asset: Asset): boolean | null {
// Fast Check #1: If asset is in multiple bundles in same target, it's referenced
let bundlesWithAsset = this.getBundlesWithAsset(asset).filter(
(b) =>
b.target.name === bundle.target.name &&
b.target.distDir === bundle.target.distDir,
);

if (bundlesWithAsset.length > 1) {
return true;
}

// Fast Check #2: If asset is referenced by any async/conditional dependency, it's referenced
let assetNodeId = nullthrows(this._graph.getNodeIdByContentKey(asset.id));

if (
this._graph
.getNodeIdsConnectedTo(assetNodeId, bundleGraphEdgeTypes.references)
.map((id) => this._graph.getNode(id))
.some(
(node) =>
node?.type === 'dependency' &&
(node.value.priority === Priority.lazy ||
node.value.priority === Priority.conditional) &&
node.value.specifierType !== SpecifierType.url,
)
) {
return true;
}

// Fast checks failed - return null to indicate expensive computation needed
return null;
}

getReferencedAssets(bundle: Bundle): Set<Asset> {
let referencedAssets = new Set<Asset>();

// Build a map of all assets in this bundle with their dependencies
// This allows us to check all assets in a single traversal
let assetDependenciesMap = new Map<Asset, Array<Dependency>>();

this.traverseAssets(bundle, (asset) => {
// Always do fast checks (no caching)
let fastCheckResult = this.isAssetReferencedFastCheck(bundle, asset);

if (fastCheckResult === true) {
referencedAssets.add(asset);
return;
}

// Fast checks failed (fastCheckResult === null), need expensive computation
// Check if it's actually referenced via traversal

// Store dependencies for later batch checking
let dependencies = this._graph
.getNodeIdsConnectedTo(
nullthrows(this._graph.getNodeIdByContentKey(asset.id)),
)
.map((id) => nullthrows(this._graph.getNode(id)))
.filter((node) => node.type === 'dependency')
.map((node) => {
invariant(node.type === 'dependency');
return node.value;
});

if (dependencies.length > 0) {
assetDependenciesMap.set(asset, dependencies);
}
});

// If no assets need the expensive check, return early
if (assetDependenciesMap.size === 0) {
return referencedAssets;
}

// Get the assets we need to check once
let assetsToCheck = Array.from(assetDependenciesMap.keys());

// Helper function to check if all assets from assetDependenciesMap are in referencedAssets
const allAssetsReferenced = (): boolean =>
assetsToCheck.length <= referencedAssets.size &&
assetsToCheck.every((asset) => referencedAssets.has(asset));

// Do ONE traversal to check all remaining assets
// We can share visitedBundles across all assets because we check every asset
// against every visited bundle, which matches the original per-asset behavior
let siblingBundles = new Set(
this.getBundleGroupsContainingBundle(bundle).flatMap((bundleGroup) =>
this.getBundlesInBundleGroup(bundleGroup, {includeInline: true}),
),
);

let visitedBundles: Set<Bundle> = new Set();

// Single traversal from all referencers
for (let referencer of siblingBundles) {
this.traverseBundles((descendant, _, actions) => {
if (descendant.id === bundle.id) {
return;
}

if (visitedBundles.has(descendant)) {
actions.skipChildren();
return;
}

visitedBundles.add(descendant);

if (
descendant.type !== bundle.type ||
fromEnvironmentId(descendant.env).context !==
fromEnvironmentId(bundle.env).context
) {
// Don't skip children - they might be the right type!
return;
}

// Check ALL assets at once in this descendant bundle
for (let [asset, dependencies] of assetDependenciesMap) {
// Skip if already marked as referenced
if (referencedAssets.has(asset)) {
continue;
}

// Check if this descendant bundle references the asset
if (
!this.bundleHasAsset(descendant, asset) &&
dependencies.some((dependency) =>
this.bundleHasDependency(descendant, dependency),
)
) {
referencedAssets.add(asset);
}
}

// If all assets from assetDependenciesMap are now marked as referenced, we can stop early
if (allAssetsReferenced()) {
actions.stop();
return;
}
}, referencer);

// If all assets from assetDependenciesMap are referenced, no need to check more sibling bundles
if (allAssetsReferenced()) {
break;
}
}

return referencedAssets;
}

hasParentBundleOfType(bundle: Bundle, type: string): boolean {
let parents = this.getParentBundles(bundle);
return (
Expand Down
21 changes: 21 additions & 0 deletions packages/core/core/src/public/BundleGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,27 @@ export default class BundleGraph<TBundle extends IBundle>
);
}

isAssetReferencedFastCheck(bundle: IBundle, asset: IAsset): boolean | null {
return this.#graph.isAssetReferencedFastCheck(
bundleToInternalBundle(bundle),
assetToAssetValue(asset),
);
}

getReferencedAssets(bundle: IBundle): Set<IAsset> {
let internalReferencedAssets = this.#graph.getReferencedAssets(
bundleToInternalBundle(bundle),
);

// Convert internal assets to public assets
let publicReferencedAssets = new Set<IAsset>();
for (let internalAsset of internalReferencedAssets) {
publicReferencedAssets.add(assetFromValue(internalAsset, this.#options));
}

return publicReferencedAssets;
}

hasParentBundleOfType(bundle: IBundle, type: string): boolean {
return this.#graph.hasParentBundleOfType(
bundleToInternalBundle(bundle),
Expand Down
10 changes: 10 additions & 0 deletions packages/core/feature-flags/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,16 @@ export const DEFAULT_FEATURE_FLAGS = {
* @since 2025-11-05
*/
nestedPromiseImportFix: process.env.ATLASPACK_BUILD_ENV === 'test',

/**
* Precompute referenced assets in bundles to avoid repeated traversals
* during scope hoisting packaging. This optimization caches which assets
* are referenced in a bundle, reducing O(N*M) calls to O(1).
*
* @author Marcin Szczepanski <[email protected]>
* @since 2025-10-24
*/
precomputeReferencedAssets: process.env.ATLASPACK_BUILD_ENV === 'test',
};

export type FeatureFlags = typeof DEFAULT_FEATURE_FLAGS;
Expand Down
7 changes: 7 additions & 0 deletions packages/core/types-internal/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1690,6 +1690,13 @@ export interface BundleGraph<TBundle extends Bundle> {
isAssetReachableFromBundle(asset: Asset, bundle: Bundle): boolean;
/** Returns whether an asset is referenced outside the given bundle. */
isAssetReferenced(bundle: Bundle, asset: Asset): boolean;
/**
* Fast checks only for asset reference status. Returns true if fast checks succeed,
* null if expensive traversal computation is needed.
*/
isAssetReferencedFastCheck(bundle: Bundle, asset: Asset): boolean | null;
/** Returns a set of all assets that are referenced outside the given bundle. */
getReferencedAssets(bundle: Bundle): Set<Asset>;
/**
* Resolves the export `symbol` of `asset` to the source,
* stopping at the first asset after leaving `bundle`.
Expand Down
45 changes: 42 additions & 3 deletions packages/packagers/js/src/ScopeHoistingPackager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export class ScopeHoistingPackager {
useBothScopeHoistingImprovements: boolean =
getFeatureFlag('applyScopeHoistingImprovementV2') ||
getFeatureFlag('applyScopeHoistingImprovement');
referencedAssetsCache: Map<string, Set<Asset>> = new Map();

constructor(
options: PluginOptions,
Expand Down Expand Up @@ -412,6 +413,44 @@ export class ScopeHoistingPackager {
return `$parcel$global.rwr(${params.join(', ')});`;
}

// Helper to check if an asset is referenced, with cache-first + fast-check hybrid approach
isAssetReferencedInBundle(bundle: NamedBundle, asset: Asset): boolean {
// STEP 1: Check expensive computation cache first (fastest when it hits)
if (getFeatureFlag('precomputeReferencedAssets')) {
let bundleId = bundle.id;
let referencedAssets = this.referencedAssetsCache.get(bundleId);

if (referencedAssets) {
// Cache hit - fastest path (~0.001ms)
return referencedAssets.has(asset);
}
}

// STEP 2: Cache miss - try fast checks (~0.01ms)
let fastCheckResult = this.bundleGraph.isAssetReferencedFastCheck(
bundle,
asset,
);

if (fastCheckResult === true) {
// Fast check succeeded - asset is referenced
return true;
}

// STEP 3: Need expensive computation (~20-2000ms)
if (getFeatureFlag('precomputeReferencedAssets')) {
// Compute and cache expensive results for this bundle
let bundleId = bundle.id;
let referencedAssets = this.bundleGraph.getReferencedAssets(bundle);
this.referencedAssetsCache.set(bundleId, referencedAssets);

return referencedAssets.has(asset);
} else {
// No caching - fall back to original expensive method
return this.bundleGraph.isAssetReferenced(bundle, asset);
}
}

async loadAssets() {
type QueueItem = [Asset, {code: string; map: Buffer | undefined | null}];
let queue = new PromiseQueue<QueueItem>({
Expand All @@ -431,7 +470,7 @@ export class ScopeHoistingPackager {
if (
asset.meta.shouldWrap ||
this.bundle.env.sourceType === 'script' ||
this.bundleGraph.isAssetReferenced(this.bundle, asset) ||
this.isAssetReferencedInBundle(this.bundle, asset) ||
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Passing this.bundle is a bit odd. I'd just reference it directly via this in isAssetReferencedInBundle

Copy link
Contributor

Choose a reason for hiding this comment

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

Ahh I see the bundle changes in some cases. Ignore me 😅

this.bundleGraph
.getIncomingDependencies(asset)
.some((dep) => dep.meta.shouldWrap && dep.specifierType !== 'url')
Expand Down Expand Up @@ -993,7 +1032,7 @@ ${code}
referencedBundle &&
referencedBundle.getMainEntry() === resolved &&
referencedBundle.type === 'js' &&
!this.bundleGraph.isAssetReferenced(referencedBundle, resolved)
!this.isAssetReferencedInBundle(referencedBundle, resolved)
) {
this.addExternal(dep, replacements, referencedBundle);
this.externalAssets.add(resolved);
Expand Down Expand Up @@ -1792,7 +1831,7 @@ ${code}
return (
asset.sideEffects === false &&
nullthrows(this.bundleGraph.getUsedSymbols(asset)).size == 0 &&
!this.bundleGraph.isAssetReferenced(this.bundle, asset)
!this.isAssetReferencedInBundle(this.bundle, asset)
);
}

Expand Down
Loading