Skip to content
Closed
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
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/cli", "crates/diagnostics", "crates/project-resolver", "crates/ts-transform", "crates/bundler", "crates/template-compiler", "crates/npm-resolver", "crates/linker", "crates/watch", "crates/dev-server"]

[workspace.package]
version = "0.10.8"
version = "0.10.13"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["lukekania"]
Expand Down
51 changes: 32 additions & 19 deletions crates/bundler/src/concat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,24 +154,40 @@ pub fn bundle(input: &BundleInput) -> NgcResult<BundleOutput> {
.map(|c| c.filename.clone())
.collect();

// Lazy chunks consume symbols from main cross-chunk; those consumptions
// are invisible to the per-chunk shake analysis. Precompute the union
// before fan-out so the main chunk's analyze_unused_exports preserves
// them.
let externally_used: Option<HashSet<String>> = if input.options.tree_shake {
let mut lazy_consumers: Vec<PathBuf> = Vec::new();
for chunk in &chunk_graph.chunks[1..] {
lazy_consumers.extend(chunk.modules.iter().cloned());
// Every chunk consumes symbols from other chunks (main from lazy, lazy
// from main, lazy from vendor, ...) — those consumptions are invisible
// to per-chunk shake analysis. Precompute the per-provider used-name
// set before fan-out so each chunk's analyze_unused_exports preserves
// exactly what its consumers reach into.
//
// For bare npm specifiers (e.g. `'@angular/core'`), build a
// specifier → canonical-entry-path map by composing the bare-spec →
// namespace and namespace → owning-path lookups we already computed.
// Without this, the shake walker can't attribute an `import { X } from
// '@angular/core'` to its vendor chunk and the chunk falls back to
// pinning every export the package declares.
let externally_used_per_chunk: Vec<HashSet<String>> = if input.options.tree_shake {
let mut ns_to_path: HashMap<&str, &PathBuf> = HashMap::new();
for (path, ns) in &all_file_to_ns {
ns_to_path.insert(ns.as_str(), path);
}
Some(shake::collect_cross_chunk_used_names(
&lazy_consumers,
&main_chunk.modules,
let specifier_to_path: HashMap<String, PathBuf> = specifier_to_namespace
.iter()
.filter_map(|(spec, ns)| {
ns_to_path
.get(ns.as_str())
.map(|path| (spec.clone(), (*path).clone()))
})
.collect();
shake::collect_cross_chunk_used_names_per_provider(
&chunk_graph,
&input.modules,
&prefix_refs,
&specifier_to_path,
subpath_ctx,
)?)
)?
} else {
None
vec![HashSet::new(); chunk_graph.chunks.len()]
};

// Process every chunk (main + lazy/shared) in a single rayon fan-out.
Expand All @@ -185,13 +201,8 @@ pub fn bundle(input: &BundleInput) -> NgcResult<BundleOutput> {
.par_iter()
.enumerate()
.map(|(idx, chunk)| -> NgcResult<(String, ChunkBundleResult)> {
let is_main = idx == 0;
let unused_exports = if input.options.tree_shake {
let externally_used_ref = if is_main {
externally_used.as_ref()
} else {
None
};
let externally_used_ref = externally_used_per_chunk.get(idx);
shake::analyze_unused_exports(
&chunk.modules,
&input.modules,
Expand Down Expand Up @@ -635,10 +646,12 @@ fn bundle_chunk(p: &ChunkBundleParams<'_>) -> NgcResult<ChunkBundleResult> {
// or another — cross-chunk refs become imports at the
// chunk's top, emitted in the post-process pass.
let namespace = &file_to_namespace[module_path];
let module_unused = p.unused_exports.get(module_path);
let wrapped = crate::npm_wrap::wrap_npm_module(
js_code,
&file_name,
namespace,
module_unused,
|specifier| {
if specifier.starts_with('.') {
let from_dir = module_path.parent()?;
Expand Down
95 changes: 81 additions & 14 deletions crates/bundler/src/npm_wrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
//! })(__ns_abc123);
//! ```

use std::collections::{BTreeSet, HashMap};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::Path;

use ngc_diagnostics::{NgcError, NgcResult};
Expand All @@ -39,15 +39,29 @@ pub struct NpmModuleInfo {
///
/// `resolve_import` is a closure that maps an import specifier to a namespace
/// variable name, or `None` if the import should be left as-is (truly external).
///
/// `unused_exports` carries the set of exported names that per-provider
/// shake decided no consumer reaches. When provided, both the declarations
/// (`export const X = ...`) and the matching `__exports.X = ...` bridge
/// lines are dropped, shrinking vendor chunks of large packages like
/// `@angular/core` whose `index.mjs` re-exports far more than any consumer
/// actually uses.
pub fn wrap_npm_module<F>(
js_code: &str,
file_name: &str,
namespace: &str,
unused_exports: Option<&HashSet<String>>,
resolve_import: F,
) -> NgcResult<NpmModuleInfo>
where
F: Fn(&str) -> Option<String>,
{
let is_unused = |name: &str| -> bool {
unused_exports
.map(|set| set.contains(name))
.unwrap_or(false)
};

// Strip sourcemap comments upfront to prevent them from interfering with
// the IIFE wrapping (they can eat export assignments on the same line).
let cleaned_code = strip_sourcemap_comments(js_code);
Expand Down Expand Up @@ -133,6 +147,9 @@ where
for spec in &export.specifiers {
let exported = spec.exported.name().to_string();
let local = spec.local.name().to_string();
if is_unused(&exported) {
continue;
}
// Don't add to exported_names — we handle the export inline
if let Some(ref ns) = target_ns {
replacements.push(format!("__exports.{exported} = {ns}.{local};"));
Expand All @@ -150,21 +167,39 @@ where
end: export.span.end,
replacement,
});
} else if export.declaration.is_some() {
// export const X = ...; → strip "export "
if let Some(decl) = &export.declaration {
collect_decl_names(decl, &mut exported_names);
} else if let Some(decl) = &export.declaration {
// export const X = ...; — if X is unused, drop the
// entire declaration so the body isn't pinned by
// its `__exports.X = X` line and any const
// initializer side-effect is also eliminated.
let mut decl_names = Vec::new();
collect_decl_names(decl, &mut decl_names);
let all_unused =
!decl_names.is_empty() && decl_names.iter().all(|n| is_unused(n));
if all_unused {
edits.push(TextEdit {
start: export.span.start,
end: export.span.end,
replacement: None,
});
} else {
for n in &decl_names {
exported_names.push(n.clone());
}
edits.push(TextEdit {
start: export.span.start,
end: export.span.start + 7, // "export "
replacement: None,
});
}
edits.push(TextEdit {
start: export.span.start,
end: export.span.start + 7, // "export "
replacement: None,
});
} else {
// export { X, Y }; or export { X as Y }; → collect names and remove
for spec in &export.specifiers {
let exported = spec.exported.name().to_string();
let local = spec.local.name().to_string();
if is_unused(&exported) {
continue;
}
if exported != local {
renamed_exports.insert(exported.clone(), local);
}
Expand Down Expand Up @@ -428,7 +463,7 @@ mod tests {
#[test]
fn test_wrap_simple_module() {
let code = "export function hello() { return 42; }\n";
let result = wrap_npm_module(code, "test.js", "__ns_test", no_resolve).unwrap();
let result = wrap_npm_module(code, "test.js", "__ns_test", None, no_resolve).unwrap();
assert!(result.wrapped_code.contains("var __ns_test = {};"));
assert!(result.wrapped_code.contains("(function(__exports)"));
assert!(result.wrapped_code.contains("__exports.hello = hello;"));
Expand All @@ -447,7 +482,7 @@ mod tests {
None
}
};
let result = wrap_npm_module(code, "test.js", "__ns_test", resolve).unwrap();
let result = wrap_npm_module(code, "test.js", "__ns_test", None, resolve).unwrap();
assert!(result
.wrapped_code
.contains("var Component = __ns_core.Component;"));
Expand All @@ -465,7 +500,7 @@ mod tests {
None
}
};
let result = wrap_npm_module(code, "test.js", "__ns_test", resolve).unwrap();
let result = wrap_npm_module(code, "test.js", "__ns_test", None, resolve).unwrap();
assert!(result
.wrapped_code
.contains("Object.assign(__exports, __ns_utils)"));
Expand All @@ -474,11 +509,43 @@ mod tests {
#[test]
fn test_wrap_default_export() {
let code = "export default function helper() { return 1; }\n";
let result = wrap_npm_module(code, "test.js", "__ns_test", no_resolve).unwrap();
let result = wrap_npm_module(code, "test.js", "__ns_test", None, no_resolve).unwrap();
assert!(result.wrapped_code.contains("function helper()"));
assert!(result.wrapped_code.contains("__exports.default = helper;"));
}

#[test]
fn test_wrap_drops_unused_declaration() {
let code = "export const used = 1;\nexport const unused = 2;\n";
let mut unused: HashSet<String> = HashSet::new();
unused.insert("unused".to_string());
let result =
wrap_npm_module(code, "test.js", "__ns_test", Some(&unused), no_resolve).unwrap();
assert!(result.wrapped_code.contains("const used = 1"));
assert!(!result.wrapped_code.contains("const unused = 2"));
assert!(result.wrapped_code.contains("__exports.used = used;"));
assert!(!result.wrapped_code.contains("__exports.unused"));
}

#[test]
fn test_wrap_drops_unused_reexport_bridge() {
let code = "export { used, unused } from './impl';\n";
let resolve = |spec: &str| -> Option<String> {
if spec == "./impl" {
Some("__ns_impl".to_string())
} else {
None
}
};
let mut unused: HashSet<String> = HashSet::new();
unused.insert("unused".to_string());
let result = wrap_npm_module(code, "test.js", "__ns_test", Some(&unused), resolve).unwrap();
assert!(result
.wrapped_code
.contains("__exports.used = __ns_impl.used"));
assert!(!result.wrapped_code.contains("__exports.unused"));
}

#[test]
fn test_namespace_from_path() {
use std::path::PathBuf;
Expand Down
Loading