diff --git a/src/resolver/package_json.rs b/src/resolver/package_json.rs index 1aa43e1a9881..86d9827ef30c 100644 --- a/src/resolver/package_json.rs +++ b/src/resolver/package_json.rs @@ -227,8 +227,8 @@ pub enum SideEffects { Unspecified, /// "sideEffects": false False, - /// "sideEffects": ["file.js", "other.js"] - Map(SideEffectsMap), + /// "sideEffects": ["file.js", "./other.js"] + Map(ExactEntries), /// "sideEffects": ["side_effects/*.js"] Glob(GlobList), /// "sideEffects": ["file.js", "side_effects/*.js"] - mixed patterns @@ -237,52 +237,128 @@ pub enum SideEffects { type SideEffectsMap = bun_collections::HashMap; -type GlobList = Vec>>; +type GlobList = Box<[glob::Prepared>]>; + +/// The entries that have no glob syntax, as one set of keys: a resolved file costs one lookup, however long the list is. +#[derive(Default)] +pub struct ExactEntries { + keys: SideEffectsMap, + /// An entry with a path separator is keyed by its path, joined onto the package directory. + has_paths: bool, + /// An entry without one is keyed as written. It is a file name, and lists that file in every directory. + has_names: bool, +} pub struct MixedPatterns { - pub(crate) exact: SideEffectsMap, + pub(crate) exact: ExactEntries, pub(crate) globs: GlobList, } +impl ExactEntries { + #[inline(always)] + fn contains(&self, path: &[u8]) -> bool { + (self.has_paths && self.has_key(path)) || (self.has_names && self.has_name_of(path)) + } + + #[inline(always)] + fn has_key(&self, key: &[u8]) -> bool { + self.keys.contains_key(&StringHashMapUnownedKey::init(key)) + } + + fn has_name_of(&self, path: &[u8]) -> bool { + self.has_key(bun_paths::basename(path)) + } +} + +fn is_glob(entry: &[u8]) -> bool { + strings::contains_any(entry, b"*?[{") || glob::has_extglob_prefix(entry) +} + +#[inline(always)] +fn any_glob_matches(globs: &[glob::Prepared>], path: &[u8]) -> bool { + if globs.is_empty() { + return false; + } + // Normalize path for cross-platform glob matching + let Ok(normalized_path) = PackageJSON::normalize_path_for_glob(path) else { + return true; + }; + for pattern in globs { + if pattern.r#match(&normalized_path).matches() { + return true; + } + } + false +} + impl SideEffects { pub(crate) fn has_side_effects(&self, path: &[u8]) -> bool { match self { SideEffects::Unspecified => true, SideEffects::False => false, - SideEffects::Map(map) => map.contains_key(&StringHashMapUnownedKey::init(path)), - SideEffects::Glob(glob_list) => { - // Normalize path for cross-platform glob matching - let Ok(normalized_path) = PackageJSON::normalize_path_for_glob(path) else { - return true; - }; - - for pattern in glob_list.iter() { - if pattern.r#match(&normalized_path).matches() { - return true; - } - } - false - } + SideEffects::Map(exact) => exact.contains(path), + SideEffects::Glob(globs) => any_glob_matches(globs, path), SideEffects::Mixed(mixed) => { - // First check exact matches - if mixed - .exact - .contains_key(&StringHashMapUnownedKey::init(path)) - { - return true; - } - // Then check glob patterns with normalized path - let Ok(normalized_path) = PackageJSON::normalize_path_for_glob(path) else { - return true; - }; + mixed.exact.contains(path) || any_glob_matches(&mixed.globs, path) + } + } + } - for pattern in mixed.globs.iter() { - if pattern.r#match(&normalized_path).matches() { - return true; - } - } - false + /// An entry without a path separator lists a file in every directory, as in webpack and esbuild. Any other is a path from `package_dir`. + fn from_array( + items: &[js_ast::E::JsonValue], + package_dir: &[u8], + r_fs: &fs::FileSystem, + ) -> SideEffects { + let entries = || items.iter().filter_map(|item| item.as_str()); + let has_globs = entries().any(is_glob); + let has_exact = entries().any(|entry| !is_glob(entry)); + // Only a list without a glob stores its `.css` entries. dce/PackageJsonSideEffectsCSSEntries pins what that changes. + let stored = + || entries().filter(|entry| !(has_globs && bun_paths::extension(entry) == b".css")); + let glob_count = stored().filter(|entry| is_glob(entry)).count(); + + let mut exact = ExactEntries { + keys: SideEffectsMap::with_capacity(stored().count() - glob_count), + ..Default::default() + }; + let mut globs = Vec::with_capacity(glob_count); + let mut spill: Vec = Vec::new(); + + for entry in stored() { + let has_separator = strings::contains_any(entry, b"/\\"); + if !is_glob(entry) { + let key = if has_separator { + r_fs.join(&mut spill, &[package_dir, entry]) + } else { + entry + }; + exact.has_paths |= has_separator; + exact.has_names |= !has_separator; + let _ = exact.keys.insert(StringHashMapUnownedKey::init(key), ()); + continue; } + let pattern = if has_separator { + let mut joined = r_fs.join(&mut spill, &[package_dir, entry]).to_vec(); + // Normalize pattern to use forward slashes for cross-platform compatibility + bun_paths::slashes_to_posix_in_place(&mut joined[..]); + joined + } else { + // No directory in the pattern, so the name of a directory is never glob syntax. + [&b"**/"[..], entry].concat() + }; + globs.push(glob::Prepared::new(pattern.into_boxed_slice())); + } + + match (has_globs, has_exact) { + // If the array is empty, treat it as false (no side effects) + (false, false) => SideEffects::False, + (false, true) => SideEffects::Map(exact), + (true, false) => SideEffects::Glob(globs.into_boxed_slice()), + (true, true) => SideEffects::Mixed(MixedPatterns { + exact, + globs: globs.into_boxed_slice(), + }), } } } @@ -704,110 +780,11 @@ impl PackageJSON { package_json.side_effects = SideEffects::False; } } else if let js_ast::ExprData::EArrayJSON(e_array) = &side_effects_field.data { - // Handle arrays, including empty arrays - let items = e_array.get().items(); - let mut map = SideEffectsMap::default(); - let mut glob_list = GlobList::default(); - let mut has_globs = false; - let mut has_exact = false; - - // First pass: check if we have glob patterns and exact patterns - for item in items { - if let Some(name) = item.as_str() { - if strings::contains_char(name, b'*') - || strings::contains_char(name, b'?') - || strings::contains_char(name, b'[') - || strings::contains_char(name, b'{') - || glob::has_extglob_prefix(name) - { - has_globs = true; - } else { - has_exact = true; - } - } - } - - let mut spill: Vec = Vec::new(); - - // If the array is empty, treat it as false (no side effects) - if !has_globs && !has_exact { - package_json.side_effects = SideEffects::False; - } else if has_globs && has_exact { - // Mixed patterns - use both exact and glob matching - map.reserve(items.len()); - glob_list.reserve(items.len()); - - for item in items { - if let Some(name) = item.as_str() { - // Skip CSS files as they're not relevant for tree-shaking - if bun_paths::extension(name) == b".css" { - continue; - } - - // Store the pattern relative to the package directory - let joined: [&[u8]; 2] = - [json_source.path.name().dir_with_trailing_slash(), name]; - - let pattern = r_fs.join(&mut spill, &joined); - - if strings::contains_char(name, b'*') - || strings::contains_char(name, b'?') - || strings::contains_char(name, b'[') - || strings::contains_char(name, b'{') - || glob::has_extglob_prefix(name) - { - // Normalize pattern to use forward slashes for cross-platform compatibility - let normalized_pattern = Self::normalize_path_for_glob(pattern) - .unwrap_or_else(|_| pattern.to_vec()); - glob_list.push(glob::Prepared::new( - normalized_pattern.into_boxed_slice(), - )); - } else { - let _ = map.insert(StringHashMapUnownedKey::init(pattern), ()); - } - } - } - package_json.side_effects = SideEffects::Mixed(MixedPatterns { - exact: map, - globs: glob_list, - }); - } else if has_globs { - // Only glob patterns - glob_list.reserve(items.len()); - for item in items { - if let Some(name) = item.as_str() { - // Skip CSS files as they're not relevant for tree-shaking - if bun_paths::extension(name) == b".css" { - continue; - } - - // Store the pattern relative to the package directory - let joined: [&[u8]; 2] = - [json_source.path.name().dir_with_trailing_slash(), name]; - - let pattern = r_fs.join(&mut spill, &joined); - // Normalize pattern to use forward slashes for cross-platform compatibility - let normalized_pattern = Self::normalize_path_for_glob(pattern) - .unwrap_or_else(|_| pattern.to_vec()); - glob_list - .push(glob::Prepared::new(normalized_pattern.into_boxed_slice())); - } - } - package_json.side_effects = SideEffects::Glob(glob_list); - } else { - // Only exact matches - map.reserve(items.len()); - for item in items { - if let Some(name) = item.as_str() { - let joined: [&[u8]; 2] = - [json_source.path.name().dir_with_trailing_slash(), name]; - - let pattern = r_fs.join(&mut spill, &joined); - let _ = map.insert(StringHashMapUnownedKey::init(pattern), ()); - } - } - package_json.side_effects = SideEffects::Map(map); - } + package_json.side_effects = SideEffects::from_array( + e_array.get().items(), + json_source.path.name().dir_with_trailing_slash(), + r_fs, + ); } } diff --git a/test/bundler/bundler_barrel.test.ts b/test/bundler/bundler_barrel.test.ts index 5cb287c7b1b2..08dba30c2888 100644 --- a/test/bundler/bundler_barrel.test.ts +++ b/test/bundler/bundler_barrel.test.ts @@ -150,6 +150,41 @@ describe("bundler", () => { }, }); + // An entry without "/" lists the file in every directory of the package. + for (const [id, entry] of [ + ["FileName", "effect.js"], + ["GlobWithoutSlash", "effect.*"], + ]) { + itBundled(`barrel/SideEffectsArray${id}KeepsListedReExport`, { + files: { + "/entry.js": /* js */ ` + import { plain } from 'effectful'; + console.log(plain('x')); + `, + "/node_modules/effectful/package.json": JSON.stringify({ + name: "effectful", + main: "./src/index.js", + sideEffects: [entry], + }), + "/node_modules/effectful/src/index.js": /* js */ ` + export { plain } from './plain.js'; + export { TABLE } from './effect.js'; + `, + "/node_modules/effectful/src/plain.js": /* js */ ` + export const plain = s => s + "!"; + `, + "/node_modules/effectful/src/effect.js": /* js */ ` + export const TABLE = {}; + TABLE.marker = "THE_SIDE_EFFECT_RAN"; + `, + }, + outdir: "/out", + onAfterBundle(api) { + api.expectFile("/out/entry.js").toContain("THE_SIDE_EFFECT_RAN"); + }, + }); + } + // A module the array does not list is still tree-shaken away. itBundled("barrel/SideEffectsArrayDropsUnlistedReExport", { files: { diff --git a/test/bundler/esbuild/dce.test.ts b/test/bundler/esbuild/dce.test.ts index f3f159d484c3..37ecbb603910 100644 --- a/test/bundler/esbuild/dce.test.ts +++ b/test/bundler/esbuild/dce.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test"; +import { isWindows } from "harness"; import { dedent, itBundled } from "../expectBundled"; // Tests ported from: @@ -801,6 +802,463 @@ describe("bundler", () => { stdout: "main effect - should be preserved\ndone", }, }); + // An entry without "/" lists a file in every directory of the package, as in webpack and esbuild. + for (const backend of ["cli", "api"] as const) { + itBundled(`dce/PackageJsonSideEffectsNoSlashAnyDirectory-${backend}`, { + backend, + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import "demo-pkg/root.side.js"; + import "demo-pkg/deep/nested.side.js"; + import "demo-pkg/.hidden/x.side.js"; + import "demo-pkg/deep/.dot.side.js"; + import "demo-pkg/polyfill.js"; + import "demo-pkg/comp/polyfill.js"; + import "demo-pkg/deep/unlisted.js"; + console.log("done"); + `, + "/Users/user/project/node_modules/demo-pkg/root.side.js": `console.log("root glob")`, + "/Users/user/project/node_modules/demo-pkg/deep/nested.side.js": `console.log("nested glob")`, + "/Users/user/project/node_modules/demo-pkg/.hidden/x.side.js": `console.log("dot directory")`, + "/Users/user/project/node_modules/demo-pkg/deep/.dot.side.js": `console.log("dotfile")`, + "/Users/user/project/node_modules/demo-pkg/polyfill.js": `console.log("root name")`, + "/Users/user/project/node_modules/demo-pkg/comp/polyfill.js": `console.log("nested name")`, + "/Users/user/project/node_modules/demo-pkg/deep/unlisted.js": `console.log("TEST FAILED")`, + "/Users/user/project/node_modules/demo-pkg/package.json": /* json */ ` + { + "sideEffects": ["*.side.js", "polyfill.js"] + } + `, + }, + dce: true, + run: { + stdout: "root glob\nnested glob\ndot directory\ndotfile\nroot name\nnested name\ndone", + }, + }); + } + itBundled("dce/PackageJsonSideEffectsGlobNoSlashMatchesNested", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import "demo-pkg/root.side.js"; + import "demo-pkg/deep/nested.side.js"; + import "demo-pkg/deep/also/here.side.js"; + import "demo-pkg/deep/unrelated.js"; + console.log("done"); + `, + "/Users/user/project/node_modules/demo-pkg/root.side.js": `console.log("root kept")`, + "/Users/user/project/node_modules/demo-pkg/deep/nested.side.js": `console.log("nested kept")`, + "/Users/user/project/node_modules/demo-pkg/deep/also/here.side.js": `console.log("deeper kept")`, + "/Users/user/project/node_modules/demo-pkg/deep/unrelated.js": `console.log("TEST FAILED")`, + "/Users/user/project/node_modules/demo-pkg/package.json": /* json */ ` + { + "sideEffects": ["*.side.js"] + } + `, + }, + dce: true, + run: { + stdout: "root kept\nnested kept\ndeeper kept\ndone", + }, + }); + itBundled("dce/PackageJsonSideEffectsBareNameMatchesNested", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import "demo-pkg/polyfill.js"; + import "demo-pkg/comp/polyfill.js"; + import "demo-pkg/comp/other.js"; + console.log("done"); + `, + "/Users/user/project/node_modules/demo-pkg/polyfill.js": `console.log("root poly kept")`, + "/Users/user/project/node_modules/demo-pkg/comp/polyfill.js": `console.log("nested poly kept")`, + "/Users/user/project/node_modules/demo-pkg/comp/other.js": `console.log("TEST FAILED")`, + "/Users/user/project/node_modules/demo-pkg/package.json": /* json */ ` + { + "sideEffects": ["polyfill.js"] + } + `, + }, + dce: true, + run: { + stdout: "root poly kept\nnested poly kept\ndone", + }, + }); + itBundled("dce/PackageJsonSideEffectsMixedNoSlashAndPath", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import "demo-pkg/lib/effect.side.js"; + import "demo-pkg/lib/specific.js"; + import "demo-pkg/comp/polyfill.js"; + import "demo-pkg/lib/drop.js"; + console.log("done"); + `, + "/Users/user/project/node_modules/demo-pkg/lib/effect.side.js": `console.log("glob kept")`, + "/Users/user/project/node_modules/demo-pkg/lib/specific.js": `console.log("exact kept")`, + "/Users/user/project/node_modules/demo-pkg/comp/polyfill.js": `console.log("bare kept")`, + "/Users/user/project/node_modules/demo-pkg/lib/drop.js": `console.log("TEST FAILED")`, + "/Users/user/project/node_modules/demo-pkg/package.json": /* json */ ` + { + "sideEffects": ["*.side.js", "./lib/specific.js", "polyfill.js"] + } + `, + }, + dce: true, + run: { + stdout: "glob kept\nexact kept\nbare kept\ndone", + }, + }); + itBundled("dce/PackageJsonSideEffectsStarListsEveryFile", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import "demo-pkg/a.js"; + import "demo-pkg/deep/b.js"; + import "demo-pkg/deep/.hidden/c.js"; + console.log("done"); + `, + "/Users/user/project/node_modules/demo-pkg/a.js": `console.log("a")`, + "/Users/user/project/node_modules/demo-pkg/deep/b.js": `console.log("b")`, + "/Users/user/project/node_modules/demo-pkg/deep/.hidden/c.js": `console.log("c")`, + "/Users/user/project/node_modules/demo-pkg/package.json": /* json */ ` + { + "sideEffects": ["*"] + } + `, + }, + run: { + stdout: "a\nb\nc\ndone", + }, + }); + itBundled("dce/PackageJsonSideEffectsBareCSSAndJS", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import "demo-pkg/style.css"; + import "demo-pkg/setup.js"; + import "demo-pkg/lib/setup.js"; + import "demo-pkg/lib/other.js"; + console.log("done"); + `, + "/Users/user/project/node_modules/demo-pkg/style.css": `body { color: red; }`, + "/Users/user/project/node_modules/demo-pkg/setup.js": `console.log("root setup kept")`, + "/Users/user/project/node_modules/demo-pkg/lib/setup.js": `console.log("nested setup kept")`, + "/Users/user/project/node_modules/demo-pkg/lib/other.js": `console.log("TEST FAILED")`, + "/Users/user/project/node_modules/demo-pkg/package.json": /* json */ ` + { + "sideEffects": ["style.css", "setup.js"] + } + `, + }, + outdir: "/out", + dce: true, + dceKeepMarkerCount: false, + run: { + stdout: "root setup kept\nnested setup kept\ndone", + }, + onAfterBundle(api) { + api.expectFile("/out/entry.css").toContain("color: red"); + }, + }); + // The list of a package that has a name, reached through the index of the package. + itBundled("dce/PackageJsonSideEffectsNoSlashInNamedPackage", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import { v } from "@scope/ui"; + console.log(v); + `, + "/Users/user/project/node_modules/@scope/ui/index.js": /* js */ ` + import "./root.side.js"; + import "./sub/deep.side.js"; + import "./comp/polyfill.js"; + import "./sub/unlisted.js"; + export const v = "done"; + `, + "/Users/user/project/node_modules/@scope/ui/root.side.js": `console.log("root glob")`, + "/Users/user/project/node_modules/@scope/ui/sub/deep.side.js": `console.log("nested glob")`, + "/Users/user/project/node_modules/@scope/ui/comp/polyfill.js": `console.log("nested name")`, + "/Users/user/project/node_modules/@scope/ui/sub/unlisted.js": `console.log("TEST FAILED")`, + "/Users/user/project/node_modules/@scope/ui/package.json": /* json */ ` + { + "name": "@scope/ui", + "version": "1.0.0", + "main": "index.js", + "sideEffects": ["*.side.js", "polyfill.js"] + } + `, + }, + dce: true, + run: { + stdout: "root glob\nnested glob\nnested name\ndone", + }, + }); + // The list of the project itself, for files that the project imports by relative path. + itBundled("dce/PackageJsonSideEffectsNoSlashInProjectPackage", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import "./deep/nested.side.js"; + import "./deep/polyfill.js"; + import "./deep/unlisted.js"; + console.log("done"); + `, + "/Users/user/project/src/deep/nested.side.js": `console.log("nested glob")`, + "/Users/user/project/src/deep/polyfill.js": `console.log("nested name")`, + "/Users/user/project/src/deep/unlisted.js": `console.log("TEST FAILED")`, + "/Users/user/project/package.json": /* json */ ` + { + "name": "app", + "sideEffects": ["*.side.js", "polyfill.js"] + } + `, + }, + dce: true, + run: { + stdout: "nested glob\nnested name\ndone", + }, + }); + // An entry without "/" has no directory, so it also lists a file that "main" reaches outside the package. + itBundled("dce/PackageJsonSideEffectsNoSlashOutsidePackage", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import "by-name"; + import "by-glob"; + console.log("done"); + `, + "/Users/user/project/node_modules/shared/x.js": `console.log("name outside")`, + "/Users/user/project/node_modules/shared/y.js": `console.log("glob outside")`, + "/Users/user/project/node_modules/by-name/package.json": /* json */ ` + { + "name": "by-name", + "main": "../shared/x.js", + "sideEffects": ["x.js"] + } + `, + "/Users/user/project/node_modules/by-glob/package.json": /* json */ ` + { + "name": "by-glob", + "main": "../shared/y.js", + "sideEffects": ["y.*"] + } + `, + }, + run: { + stdout: "name outside\nglob outside\ndone", + }, + }); + // A file that the list names keeps its side effects when an onLoad plugin turns it into JavaScript. + itBundled("dce/PackageJsonSideEffectsListedFileLoadedByPlugin", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import "demo-pkg/root.scss"; + import "demo-pkg/scss/theme.scss"; + import "demo-pkg/scss/unlisted.less"; + console.log("done"); + `, + "/Users/user/project/node_modules/demo-pkg/root.scss": `.root { color: red }`, + "/Users/user/project/node_modules/demo-pkg/scss/theme.scss": `.theme { color: blue }`, + "/Users/user/project/node_modules/demo-pkg/scss/unlisted.less": `TEST FAILED`, + "/Users/user/project/node_modules/demo-pkg/package.json": /* json */ ` + { + "sideEffects": ["./lib/common.js", "*.css", "*.scss"] + } + `, + }, + plugins(builder) { + builder.onLoad({ filter: /\.(scss|less)$/ }, async args => ({ + contents: `console.log(${JSON.stringify((await Bun.file(args.path).text()).trim())})`, + loader: "js", + })); + }, + run: { + stdout: ".root { color: red }\n.theme { color: blue }\ndone", + }, + }); + // Today a list with a glob is asked a second time through the nearest package.json that has a name, and that + // answer wins. A list of file names has no glob, so its own answer stands. This fails if a file name is a glob. + itBundled("dce/PackageJsonSideEffectsFileNameInPackageWithoutName", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import "no-name/polyfill.js"; + import "no-name/sub/polyfill.js"; + import "no-name/sub/unlisted.js"; + console.log("done"); + `, + "/Users/user/project/package.json": /* json */ ` + { + "name": "app", + "sideEffects": false + } + `, + "/Users/user/project/node_modules/no-name/polyfill.js": `console.log("root name")`, + "/Users/user/project/node_modules/no-name/sub/polyfill.js": `console.log("nested name")`, + "/Users/user/project/node_modules/no-name/sub/unlisted.js": `console.log("TEST FAILED")`, + "/Users/user/project/node_modules/no-name/package.json": /* json */ ` + { + "sideEffects": ["polyfill.js"] + } + `, + }, + dce: true, + run: { + stdout: "root name\nnested name\ndone", + }, + }); + // "[slug]" in the path of the package is not a character class for an entry without "/". + itBundled("dce/PackageJsonSideEffectsNoSlashUnderBracketDirectory", { + files: { + "/Users/user/project/[slug]/src/entry.js": /* js */ ` + import "demo-pkg/root.side.js"; + import "demo-pkg/deep/nested.side.js"; + import "demo-pkg/polyfill.js"; + import "demo-pkg/deep/polyfill.js"; + import "demo-pkg/deep/unlisted.js"; + console.log("done"); + `, + "/Users/user/project/[slug]/node_modules/demo-pkg/root.side.js": `console.log("root glob")`, + "/Users/user/project/[slug]/node_modules/demo-pkg/deep/nested.side.js": `console.log("nested glob")`, + "/Users/user/project/[slug]/node_modules/demo-pkg/polyfill.js": `console.log("root name")`, + "/Users/user/project/[slug]/node_modules/demo-pkg/deep/polyfill.js": `console.log("nested name")`, + "/Users/user/project/[slug]/node_modules/demo-pkg/deep/unlisted.js": `console.log("TEST FAILED")`, + "/Users/user/project/[slug]/node_modules/demo-pkg/package.json": /* json */ ` + { + "sideEffects": ["*.side.js", "polyfill.js"] + } + `, + }, + dce: true, + run: { + stdout: "root glob\nnested glob\nroot name\nnested name\ndone", + }, + }); + // An entry with "/" is a path from the directory of the package.json. + itBundled("dce/PackageJsonSideEffectsEntryWithSlashIsAPath", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import "demo-pkg/polyfill.js"; + import "demo-pkg/lib/polyfill.js"; + import "demo-pkg/root.side.js"; + import "demo-pkg/lib/nested.side.js"; + import "demo-pkg/comp/setup.js"; + import "demo-pkg/lib/comp/setup.js"; + console.log("done"); + `, + "/Users/user/project/node_modules/demo-pkg/polyfill.js": `console.log("root name")`, + "/Users/user/project/node_modules/demo-pkg/lib/polyfill.js": `console.log("TEST FAILED")`, + "/Users/user/project/node_modules/demo-pkg/root.side.js": `console.log("root glob")`, + "/Users/user/project/node_modules/demo-pkg/lib/nested.side.js": `console.log("TEST FAILED")`, + "/Users/user/project/node_modules/demo-pkg/comp/setup.js": `console.log("comp path")`, + "/Users/user/project/node_modules/demo-pkg/lib/comp/setup.js": `console.log("TEST FAILED")`, + "/Users/user/project/node_modules/demo-pkg/package.json": /* json */ ` + { + "sideEffects": ["./polyfill.js", "./*.side.js", "comp/setup.js"] + } + `, + }, + dce: true, + run: { + stdout: "root name\nroot glob\ncomp path\ndone", + }, + }); + // An entry with "\\" is a path, as one with "/" is. With glob syntax its "\\" becomes "/" on every platform. + // Without glob syntax it is joined as written: a directory separator on Windows, a part of the name elsewhere. + itBundled("dce/PackageJsonSideEffectsEntryWithBackslashIsAPath", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import "demo-pkg/sub/effect.js"; + import "demo-pkg/globbed/x.js"; + import "demo-pkg/deep/globbed/x.js"; + console.log("done"); + `, + "/Users/user/project/node_modules/demo-pkg/sub/effect.js": `console.log("sub effect")`, + "/Users/user/project/node_modules/demo-pkg/globbed/x.js": `console.log("globbed x")`, + "/Users/user/project/node_modules/demo-pkg/deep/globbed/x.js": `console.log("TEST FAILED")`, + "/Users/user/project/node_modules/demo-pkg/package.json": /* json */ ` + { + "sideEffects": ["sub\\\\effect.js", "globbed\\\\*.js"] + } + `, + }, + dce: true, + run: { + stdout: isWindows ? "sub effect\nglobbed x\ndone" : "globbed x\ndone", + }, + }); + itBundled("dce/PackageJsonSideEffectsExactListInPackageWithoutName", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import "no-name/listed.js"; + import "no-name/unlisted.js"; + console.log("done"); + `, + "/Users/user/project/package.json": /* json */ ` + { + "name": "app", + "sideEffects": false + } + `, + "/Users/user/project/node_modules/no-name/listed.js": `console.log("listed")`, + "/Users/user/project/node_modules/no-name/unlisted.js": `console.log("TEST FAILED")`, + "/Users/user/project/node_modules/no-name/package.json": /* json */ ` + { + "sideEffects": ["./listed.js"] + } + `, + }, + dce: true, + run: { + stdout: "listed\ndone", + }, + }); + // A list that has a glob does not store its ".css" entries, and a list without a glob stores them. This pins + // that difference as it is. The CSS is in the bundle either way. A lazily loaded importer keeps an empty + // initializer for a CSS file with side effects. + itBundled("dce/PackageJsonSideEffectsCSSEntries", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + console.log(require("with-glob").name, require("with-both").name, require("with-path").name); + `, + "/Users/user/project/node_modules/with-glob/index.js": /* js */ ` + import "./sub/globbed.css"; + export const name = "glob"; + `, + "/Users/user/project/node_modules/with-glob/sub/globbed.css": `.globbed { color: red; }`, + "/Users/user/project/node_modules/with-glob/package.json": /* json */ ` + { + "sideEffects": ["*.css"] + } + `, + "/Users/user/project/node_modules/with-both/index.js": /* js */ ` + import "./sub/mixed.css"; + export const name = "both"; + `, + "/Users/user/project/node_modules/with-both/sub/mixed.css": `.mixed { color: green; }`, + "/Users/user/project/node_modules/with-both/package.json": /* json */ ` + { + "sideEffects": ["./sub/mixed.css", "./*.side.js"] + } + `, + "/Users/user/project/node_modules/with-path/index.js": /* js */ ` + import "./sub/exact.css"; + export const name = "path"; + `, + "/Users/user/project/node_modules/with-path/sub/exact.css": `.exact { color: blue; }`, + "/Users/user/project/node_modules/with-path/package.json": /* json */ ` + { + "sideEffects": ["./sub/exact.css"] + } + `, + }, + outdir: "/out", + run: { + stdout: "glob both path", + }, + onAfterBundle(api) { + const js = api.readFile("/out/entry.js"); + expect(js).not.toContain("init_globbed"); + expect(js).not.toContain("init_mixed"); + expect(js).toContain("init_exact"); + const css = api.readFile("/out/entry.css"); + expect(css).toContain(".globbed"); + expect(css).toContain(".mixed"); + expect(css).toContain(".exact"); + }, + }); itBundled("dce/PackageJsonSideEffectsGlobInvalidPattern", { files: { "/Users/user/project/src/entry.js": /* js */ ` diff --git a/test/bundler/esbuild/extra.test.ts b/test/bundler/esbuild/extra.test.ts index 7cd5621a0b63..6db05156de97 100644 --- a/test/bundler/esbuild/extra.test.ts +++ b/test/bundler/esbuild/extra.test.ts @@ -1314,7 +1314,6 @@ describe("bundler", () => { "foo/dir/x.js": `global.dce6 = 123`, "foo/package.json": `{ "main": "dir/x", "sideEffects": ["x.*"] }`, }, - skipIfWeDidNotImplementWildcardSideEffects: true, run: true, }); itBundled(`extra/TreeShaking9`, { @@ -1323,7 +1322,6 @@ describe("bundler", () => { "foo/dir/x.js": `global.dce6 = 123`, "foo/package.json": `{ "main": "dir/x", "sideEffects": ["**/x.*"] }`, }, - skipIfWeDidNotImplementWildcardSideEffects: true, run: true, }); itBundled(`extra/TreeShaking10`, { diff --git a/test/bundler/expectBundled.ts b/test/bundler/expectBundled.ts index ad5ccafccb1e..0b9d1e4cd863 100644 --- a/test/bundler/expectBundled.ts +++ b/test/bundler/expectBundled.ts @@ -344,9 +344,6 @@ export interface BundlerTestInput { /** Run after bundle happens but before runtime. */ onAfterBundle?(api: BundlerTestBundleAPI): Promise | void; - /* TODO: remove this from the tests after this is implemented */ - skipIfWeDidNotImplementWildcardSideEffects?: boolean; - snapshotSourceMap?: Record; expectExactFilesize?: Record; @@ -575,7 +572,6 @@ function expectBundled( mangleProps, mangleQuoted, nodePaths, - skipIfWeDidNotImplementWildcardSideEffects, stdin, targetFromAPI, mode, @@ -634,7 +630,6 @@ function expectBundled( mangleProps, mangleQuoted, nodePaths, - skipIfWeDidNotImplementWildcardSideEffects, stdin, targetFromAPI, })) {