diff --git a/crates/next-api/src/nft.rs b/crates/next-api/src/nft.rs index e425afe16965..933e83c7bd94 100644 --- a/crates/next-api/src/nft.rs +++ b/crates/next-api/src/nft.rs @@ -10,7 +10,7 @@ use turbo_tasks::{ FxIndexMap, FxIndexSet, ReadRef, ResolvedVc, TraitRef, TryFlatJoinIterExt, TryJoinIterExt, Vc, }; use turbo_tasks_fs::{ - DirectoryEntry, FileSystemPath, + DirectoryEntry, FileSystemEntryType, FileSystemPath, glob::{Glob, GlobOptions}, }; use turbo_tasks_hash::HashAlgorithm; @@ -193,19 +193,28 @@ async fn get_glob_includes( let glob_result = project_root_path.read_glob(glob).await?; // Walk the full glob_result using an explicit stack to avoid async recursion overheads. - // Use a BTreeSet to get deterministic order (return value of `read_glob` has random order). - let mut result = vec![]; + // Deduplicate symlinks shared by many matches. The return value of `read_glob` has random + // order, so the result is sorted below. + let mut result = FxHashSet::default(); let mut stack = VecDeque::new(); stack.push_back(glob_result); while let Some(glob_result) = stack.pop_back() { - // Process direct results (files and directories at this level) + // Process direct results (files and directories at this level). for entry in glob_result.results.values() { let (DirectoryEntry::File(file_path) | DirectoryEntry::Symlink(file_path)) = entry else { continue; }; - result.push(file_path.clone()); + // ReadGlobResult paths are logical by contract. Resolve each match here so the NFT + // includes both the physical file and every symlink needed to reach it. + let realpath = file_path.realpath_with_links().await?; + result.extend(realpath.symlinks.iter().cloned()); + if let Ok(resolved_path) = &realpath.path_result + && matches!(*resolved_path.get_type().await?, FileSystemEntryType::File) + { + result.insert(resolved_path.clone()); + } } for nested_result in glob_result.inner.values() { @@ -216,8 +225,8 @@ async fn get_glob_includes( // All paths were matched from project_root_path, so they must all have the same `fs`. So it's // enough to sort by path. + let mut result: Vec<_> = result.into_iter().collect(); result.sort_by(|a, b| a.path.cmp(&b.path)); - Ok(result) } @@ -564,3 +573,81 @@ impl Issue for ForbiddenTracedFileIssue { Ok(Some(StyledString::Stack(stack))) } } + +#[cfg(all(test, unix))] +mod tests { + use std::{ + fs::{create_dir_all, write}, + os::unix::fs::symlink, + }; + + use turbo_rcstr::{RcStr, rcstr}; + use turbo_tasks::Vc; + use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage}; + use turbo_tasks_fs::{ + DiskFileSystem, FileSystem, + glob::{Glob, GlobOptions}, + }; + + use crate::nft::get_glob_includes; + + #[turbo_tasks::function(operation, root)] + async fn assert_glob_includes_operation(disk_root: RcStr) -> anyhow::Result<()> { + let root = DiskFileSystem::new(rcstr!("test"), Vc::cell(disk_root)) + .root() + .owned() + .await?; + let includes = get_glob_includes( + root, + Glob::new( + rcstr!("**"), + GlobOptions { + contains: true, + ..Default::default() + }, + ), + ) + .await?; + + assert_eq!( + includes + .iter() + .map(|path| path.path.as_str()) + .collect::>(), + [ + "alias", + "alias-chain", + "dangling", + "file-link", + "real/file.txt", + ] + ); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn glob_includes_resolve_files_and_retain_symlinks() { + let scratch = tempfile::tempdir().unwrap(); + let root = scratch.path(); + create_dir_all(root.join("real")).unwrap(); + write(root.join("real/file.txt"), "content").unwrap(); + symlink("real", root.join("alias")).unwrap(); + symlink("alias", root.join("alias-chain")).unwrap(); + symlink("real/file.txt", root.join("file-link")).unwrap(); + symlink("missing", root.join("dangling")).unwrap(); + + let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new( + BackendOptions::default(), + noop_backing_storage(), + )); + let disk_root: RcStr = root.to_str().unwrap().into(); + tt.run_once(async move { + assert_glob_includes_operation(disk_root) + .read_strongly_consistent() + .await?; + anyhow::Ok(()) + }) + .await + .unwrap(); + } +} diff --git a/crates/next-core/src/next_client/context.rs b/crates/next-core/src/next_client/context.rs index 39078f553e97..26c5078cdd1f 100644 --- a/crates/next-core/src/next_client/context.rs +++ b/crates/next-core/src/next_client/context.rs @@ -376,6 +376,7 @@ pub async fn get_client_module_options_context( source_maps, infer_module_side_effects: *next_config.turbopack_infer_module_side_effects().await?, cjs_tree_shaking: *next_config.turbopack_cjs_tree_shaking().await?, + mangle_export_names: *next_config.turbopack_mangle_export_names(mode).await?, cjs_scope_hoisting: *next_config.turbopack_cjs_scope_hoisting().await?, cross_module_constants: *next_config.turbopack_cross_module_constants().await?, preset_env_config, diff --git a/crates/next-core/src/next_client_reference/ecmascript_client_reference/ecmascript_client_reference_module.rs b/crates/next-core/src/next_client_reference/ecmascript_client_reference/ecmascript_client_reference_module.rs index 6432e4d30c18..e4a8a534da53 100644 --- a/crates/next-core/src/next_client_reference/ecmascript_client_reference/ecmascript_client_reference_module.rs +++ b/crates/next-core/src/next_client_reference/ecmascript_client_reference/ecmascript_client_reference_module.rs @@ -285,7 +285,9 @@ impl ChunkableModule for EcmascriptClientReferenceModule { impl EcmascriptChunkPlaceable for EcmascriptClientReferenceModule { #[turbo_tasks::function] fn get_exports(self: Vc) -> Vc { - self.proxy_module().get_exports() + // Borrowed from the proxy module, a separate module identity, so they must not carry a + // mangling decision — see `EcmascriptExports::borrowed`. + self.proxy_module().get_exports().borrowed() } #[turbo_tasks::function] diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index 48bc92785f46..8267f47ec256 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -1446,6 +1446,8 @@ pub struct ExperimentalConfig { turbopack_infer_module_side_effects: Option, /// Enable tree shaking of unused exports from static CommonJS modules. Defaults to false. turbopack_cjs_tree_shaking: Option, + /// Shorten ("mangle") the export names modules expose to each other. Defaults to false. + turbopack_mangle_export_names: Option, /// Enable scope hoisting of static CommonJS modules. Defaults to false. turbopack_cjs_scope_hoisting: Option, /// Enable cross-module constant inlining. Defaults to false. @@ -2587,6 +2589,22 @@ impl NextConfig { ) } + /// Whether Turbopack should shorten ("mangle") the export names modules expose to each other. + /// + /// An explicit value always wins, in either direction — setting this to `true` in development + /// is honoured. `mode` only supplies the default when the option is unset: on in production + /// builds, off in development, where the extra module splitting costs rebuild time and the + /// short names make debugging harder for no benefit. + #[turbo_tasks::function] + pub async fn turbopack_mangle_export_names(&self, mode: Vc) -> Result> { + Ok(Vc::cell( + match self.experimental.turbopack_mangle_export_names { + Some(explicit) => explicit, + None => !mode.await?.is_development(), + }, + )) + } + #[turbo_tasks::function] pub fn turbopack_cjs_scope_hoisting(&self) -> Vc { Vc::cell( diff --git a/crates/next-core/src/next_server/context.rs b/crates/next-core/src/next_server/context.rs index e0116eb344e2..7c56e5cc268f 100644 --- a/crates/next-core/src/next_server/context.rs +++ b/crates/next-core/src/next_server/context.rs @@ -551,6 +551,7 @@ pub async fn get_server_module_options_context( source_maps, infer_module_side_effects: *next_config.turbopack_infer_module_side_effects().await?, cjs_tree_shaking: *next_config.turbopack_cjs_tree_shaking().await?, + mangle_export_names: *next_config.turbopack_mangle_export_names(mode).await?, cjs_scope_hoisting: *next_config.turbopack_cjs_scope_hoisting().await?, cross_module_constants: *next_config.turbopack_cross_module_constants().await?, ..Default::default() diff --git a/docs/01-app/03-api-reference/08-turbopack.mdx b/docs/01-app/03-api-reference/08-turbopack.mdx index f76f46cbb6a7..318765dc7c82 100644 --- a/docs/01-app/03-api-reference/08-turbopack.mdx +++ b/docs/01-app/03-api-reference/08-turbopack.mdx @@ -422,26 +422,27 @@ Turbopack can be configured via `next.config.js` (or `next.config.ts`) under the Additionally, the following experimental options are available under `experimental` in `next.config.js`: -| Option | Description | Default (dev) | Default (build) | -| ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ----------------------------- | -| [`turbopackFileSystemCacheForDev`](/docs/app/api-reference/config/next-config-js/turbopackFileSystemCache) | Enable filesystem cache for the dev server. | `true` | N/A | -| [`turbopackFileSystemCacheForBuild`](/docs/app/api-reference/config/next-config-js/turbopackFileSystemCache) | Enable filesystem cache for builds. | N/A | `true`1 | -| `turbopackMinify` | Enable minification. Accepts a boolean, or `{ server, client, edge }` to configure each environment separately. | `false` | `true` | -| `turbopackSourceMaps` | Enable source maps. | `true` | `productionBrowserSourceMaps` | -| `turbopackInputSourceMaps` | Enable extraction of source maps from input files. | `true` | `true` | -| `turbopackModuleFragments` | Currently in active development. This splits modules into fragments and chunks only import the used fragments of the modules. | `false` | `false` | -| `turbopackRemoveUnusedImports` | Enable removing unused imports. Requires `turbopackRemoveUnusedExports`. | `false` | `true` | -| `turbopackRemoveUnusedExports` | Enable removing unused exports. | `false` | `true` | -| `turbopackInferModuleSideEffects` | Enable local analysis to infer side-effect-free modules for better tree shaking. | `true` | `true` | -| `turbopackScopeHoisting` | Enable scope hoisting. Always disabled in dev mode. | `false` | `true` | -| `turbopackClientSideNestedAsyncChunking` | Enable nested async chunking for client-side assets. | `false` | `true` | -| `turbopackServerSideNestedAsyncChunking` | Enable nested async chunking for server-side assets. | `false` | `false` | -| `turbopackImportTypeBytes` | Enable support for `with {type: "bytes"}` for ESM imports. | `false` | `false` | -| `turbopackUseBuiltinBabel` | Enable automatic Babel loader configuration when a Babel config file is present. | `true` | `true` | -| `turbopackUseBuiltinSass` | Enable automatic Sass loader configuration. | `true` | `true` | -| `turbopackModuleIds` | Module ID strategy: `'named'` or `'deterministic'`. | `'named'` | `'deterministic'` | -| [`turbopackLocalPostcssConfig`](/docs/app/api-reference/config/next-config-js/turbopackLocalPostcssConfig) | Resolve `postcss.config.js` from the CSS file's directory first, then the project root. | `false` | `false` | -| `turbopackWorkerAssetPrefix` | Custom asset prefix for Web Worker URLs (entrypoint + module chunks), overriding `assetPrefix`. Mirrors webpack's `output.workerPublicPath`. | `undefined` | `undefined` | +| Option | Description | Default (dev) | Default (build) | +| ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ----------------------------- | +| [`turbopackFileSystemCacheForDev`](/docs/app/api-reference/config/next-config-js/turbopackFileSystemCache) | Enable filesystem cache for the dev server. | `true` | N/A | +| [`turbopackFileSystemCacheForBuild`](/docs/app/api-reference/config/next-config-js/turbopackFileSystemCache) | Enable filesystem cache for builds. | N/A | `true`1 | +| `turbopackMinify` | Enable minification. Accepts a boolean, or `{ server, client, edge }` to configure each environment separately. | `false` | `true` | +| `turbopackSourceMaps` | Enable source maps. | `true` | `productionBrowserSourceMaps` | +| `turbopackInputSourceMaps` | Enable extraction of source maps from input files. | `true` | `true` | +| `turbopackModuleFragments` | Currently in active development. This splits modules into fragments and chunks only import the used fragments of the modules. | `false` | `false` | +| `turbopackRemoveUnusedImports` | Enable removing unused imports. Requires `turbopackRemoveUnusedExports`. | `false` | `true` | +| `turbopackRemoveUnusedExports` | Enable removing unused exports. | `false` | `true` | +| `turbopackMangleExportNames` | Shorten the export names modules expose to each other, to reduce bundle size. A module whose export names can be observed (an escaping namespace object, a dynamic `import()`, a CommonJS `require()`) keeps its original names. | `false` | `false` | +| `turbopackInferModuleSideEffects` | Enable local analysis to infer side-effect-free modules for better tree shaking. | `true` | `true` | +| `turbopackScopeHoisting` | Enable scope hoisting. Always disabled in dev mode. | `false` | `true` | +| `turbopackClientSideNestedAsyncChunking` | Enable nested async chunking for client-side assets. | `false` | `true` | +| `turbopackServerSideNestedAsyncChunking` | Enable nested async chunking for server-side assets. | `false` | `false` | +| `turbopackImportTypeBytes` | Enable support for `with {type: "bytes"}` for ESM imports. | `false` | `false` | +| `turbopackUseBuiltinBabel` | Enable automatic Babel loader configuration when a Babel config file is present. | `true` | `true` | +| `turbopackUseBuiltinSass` | Enable automatic Sass loader configuration. | `true` | `true` | +| `turbopackModuleIds` | Module ID strategy: `'named'` or `'deterministic'`. | `'named'` | `'deterministic'` | +| [`turbopackLocalPostcssConfig`](/docs/app/api-reference/config/next-config-js/turbopackLocalPostcssConfig) | Resolve `postcss.config.js` from the CSS file's directory first, then the project root. | `false` | `false` | +| `turbopackWorkerAssetPrefix` | Custom asset prefix for Web Worker URLs (entrypoint + module chunks), overriding `assetPrefix`. Mirrors webpack's `output.workerPublicPath`. | `undefined` | `undefined` | **Table notes:** diff --git a/lerna.json b/lerna.json index 04824ab1f7a9..1abd07f907ce 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.4.0-canary.9" + "version": "16.4.0-canary.10" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 71258a474d2c..cf56b456fd6d 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "keywords": [ "react", "next", diff --git a/packages/devlow-bench/package.json b/packages/devlow-bench/package.json index 303e4ae2ee51..293b6a02d0ae 100644 --- a/packages/devlow-bench/package.json +++ b/packages/devlow-bench/package.json @@ -1,7 +1,7 @@ { "name": "@vercel/devlow-bench", "private": true, - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "description": "Benchmarking tool for the developer workflow", "repository": { "type": "git", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 542da0d12882..c03c631fc11e 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.4.0-canary.9", + "@next/eslint-plugin-next": "16.4.0-canary.10", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index e3f646e8057a..187883fffc01 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index f4d15882b4ec..f8847e7785ff 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index cdeead0ad9d5..b19dcdbcad2c 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 718ddf415121..09409943ab50 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index e3241759aa4a..bd5c6dc82cfd 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 8ab949ff0093..50daed34841d 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 02f27860793d..4e19075d1408 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index ecba30b4d73e..9656d7411628 100644 --- a/packages/next-playwright/package.json +++ b/packages/next-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@next/playwright", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "repository": { "url": "vercel/next.js", "directory": "packages/next-playwright" diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 98a9a7760487..f3f3ddc055f0 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index b80f09efc720..27bbfee20b09 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index ca359cb692e9..be939f2fde28 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index 6da1f2bb499f..b93ef8b3eb46 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index ba0514f5c0cb..560c76396d82 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 6a36849bcd7c..59286d533d02 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index 71d761b7aa49..321ba3dc6367 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -100,7 +100,7 @@ ] }, "dependencies": { - "@next/env": "16.4.0-canary.9", + "@next/env": "16.4.0-canary.10", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -164,11 +164,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.42.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.4.0-canary.9", - "@next/polyfill-module": "16.4.0-canary.9", - "@next/polyfill-nomodule": "16.4.0-canary.9", - "@next/react-refresh-utils": "16.4.0-canary.9", - "@next/swc": "16.4.0-canary.9", + "@next/font": "16.4.0-canary.10", + "@next/polyfill-module": "16.4.0-canary.10", + "@next/polyfill-nomodule": "16.4.0-canary.10", + "@next/react-refresh-utils": "16.4.0-canary.10", + "@next/swc": "16.4.0-canary.10", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.61.0", "@rspack/core": "1.6.7", diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index e50c6fcf8a44..1cb970fee817 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -423,6 +423,7 @@ export const experimentalSchema = { turbopackModuleIds: z.enum(['named', 'deterministic']).optional(), turbopackInferModuleSideEffects: z.boolean().optional(), turbopackCjsTreeShaking: z.boolean().optional(), + turbopackMangleExportNames: z.boolean().optional(), turbopackCjsScopeHoisting: z.boolean().optional(), turbopackCrossModuleConstants: z.boolean().optional(), turbopackServerFastRefresh: z.boolean().optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index d4d39625cd1c..54b8e9448670 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -1011,6 +1011,16 @@ export interface ExperimentalConfig { */ turbopackCjsTreeShaking?: boolean + /** + * Shorten ("mangle") the export names modules expose to each other in Turbopack, to reduce + * bundle size. Only affects the keys used to link modules together: a module whose export names + * can be observed by user code (a namespace object that escapes, a dynamic `import()`, a + * CommonJS `require()`) keeps its original names. + * + * Defaults to `false` + */ + turbopackMangleExportNames?: boolean + /** * Enable scope hoisting of static CommonJS modules. * @@ -2371,6 +2381,10 @@ export const defaultConfig = Object.freeze({ turbopackInferModuleSideEffects: true, turbopackPluginRuntimeStrategy: 'childProcesses', turbopackSharedRuntime: !isStableBuild(), + // Pinned off for stable releases. Left unset on canary so the Turbopack side picks the + // default from the build mode (on for production builds, off in development) — see + // `NextConfig::turbopack_mangle_export_names`. An explicit value always wins either way. + turbopackMangleExportNames: isStableBuild() ? false : undefined, }, htmlLimitedBots: undefined, bundlePagesRouterDependencies: false, diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 35c9cee16be4..3df93e3f67e7 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index c4ca16a8e89d..7d450309beea 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "16.4.0-canary.9", + "version": "16.4.0-canary.10", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "16.4.0-canary.9", + "next": "16.4.0-canary.10", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "6.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9d7630ecc87..0ae4a6114100 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1024,7 +1024,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.4.0-canary.9 + specifier: 16.4.0-canary.10 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1107,7 +1107,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.4.0-canary.9 + specifier: 16.4.0-canary.10 version: link:../next-env '@swc/helpers': specifier: 0.5.23 @@ -1228,19 +1228,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.4.0-canary.9 + specifier: 16.4.0-canary.10 version: link:../font '@next/polyfill-module': - specifier: 16.4.0-canary.9 + specifier: 16.4.0-canary.10 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.4.0-canary.9 + specifier: 16.4.0-canary.10 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.4.0-canary.9 + specifier: 16.4.0-canary.10 version: link:../react-refresh-utils '@next/swc': - specifier: 16.4.0-canary.9 + specifier: 16.4.0-canary.10 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1983,7 +1983,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.4.0-canary.9 + specifier: 16.4.0-canary.10 version: link:../next outdent: specifier: 0.8.0 diff --git a/test/e2e/app-dir/webpack-loader-fs/app/glob-target/inner/path/one.txt b/test/e2e/app-dir/webpack-loader-fs/app/glob-target/inner/path/one.txt new file mode 100644 index 000000000000..5626abf0f72e --- /dev/null +++ b/test/e2e/app-dir/webpack-loader-fs/app/glob-target/inner/path/one.txt @@ -0,0 +1 @@ +one diff --git a/test/e2e/app-dir/webpack-loader-fs/app/path/to/symlink b/test/e2e/app-dir/webpack-loader-fs/app/path/to/symlink new file mode 120000 index 000000000000..f6748fcc8385 --- /dev/null +++ b/test/e2e/app-dir/webpack-loader-fs/app/path/to/symlink @@ -0,0 +1 @@ +../../glob-target \ No newline at end of file diff --git a/test/e2e/app-dir/webpack-loader-fs/test-file-loader.js b/test/e2e/app-dir/webpack-loader-fs/test-file-loader.js index 3f0cc52c1528..66c7e1c8ccd7 100644 --- a/test/e2e/app-dir/webpack-loader-fs/test-file-loader.js +++ b/test/e2e/app-dir/webpack-loader-fs/test-file-loader.js @@ -1,3 +1,4 @@ +const fs = require('fs') const path = require('path') module.exports = async function (content) { @@ -21,5 +22,13 @@ module.exports = async function (content) { res(data) }) ) - return `module.exports = "Buffer read: ${read1 instanceof Buffer ? read1.length : 0}, string read: '${read2.trim()}', binary read: ${read3.length}"` + const globDir = path.join(dir, 'path/to/symlink/inner/path') + const globEntries = await new Promise((res, rej) => + fs.readdir(globDir, (err, entries) => { + if (err) return rej(err) + res(entries) + }) + ) + this.addContextDependency(globDir) + return `module.exports = "Buffer read: ${read1 instanceof Buffer ? read1.length : 0}, string read: '${read2.trim()}', binary read: ${read3.length}, glob read: '${globEntries.sort().join(',')}'"` } diff --git a/test/e2e/app-dir/webpack-loader-fs/webpack-loader-fs.test.ts b/test/e2e/app-dir/webpack-loader-fs/webpack-loader-fs.test.ts index 080d00918e01..f247c44ade27 100644 --- a/test/e2e/app-dir/webpack-loader-fs/webpack-loader-fs.test.ts +++ b/test/e2e/app-dir/webpack-loader-fs/webpack-loader-fs.test.ts @@ -11,7 +11,7 @@ describe('webpack-loader-fs', () => { it('should allow reading the input FS', async () => { const $ = await next.render$('/') expect($('#test').text()).toBe( - "Buffer read: 18, string read: 'this is some data', binary read: 6765" + "Buffer read: 18, string read: 'this is some data', binary read: 6765, glob read: 'one.txt'" ) }) }) diff --git a/test/production/build-trace-extra-entries-turbo/build-trace-extra-entries-turbo.test.ts b/test/production/build-trace-extra-entries-turbo/build-trace-extra-entries-turbo.test.ts index 5f5207ca340f..ecf85c2520ea 100644 --- a/test/production/build-trace-extra-entries-turbo/build-trace-extra-entries-turbo.test.ts +++ b/test/production/build-trace-extra-entries-turbo/build-trace-extra-entries-turbo.test.ts @@ -74,8 +74,8 @@ describe('build trace with extra entries', () => { ).toBe(true) if (isTurbopack) { // A symlink matched by outputFileTracingIncludes is traced as the symlink itself, even - // when it points at a directory (this used to fail the build with - // `reading file "..." Is a directory (os error 21)`). + // when it points at a directory. Files reached through it are traced at their resolved + // paths so deployment includes both the link and its target contents. // The webpack tracer globs with `nodir: true`, which drops directory symlinks, so this // only applies to Turbopack. expect( @@ -83,11 +83,27 @@ describe('build trace with extra entries', () => { (file: string) => file === '../../../include-me/link-to-dir' ) ).toBe(true) + expect( + tracedFiles.some( + (file: string) => file === '../../../content/hello.json' + ) + ).toBe(true) + expect( + tracedFiles.some( + (file: string) => + file === '../../../include-me/link-to-dir/hello.json' + ) + ).toBe(false) expect( appDirRoute1Trace.files.some( (file: string) => file === '../../../../include-me/link-to-dir' ) ).toBe(true) + expect( + appDirRoute1Trace.files.some( + (file: string) => file === '../../../../content/hello.json' + ) + ).toBe(true) } expect( indexTrace.files.some((file: string) => file.includes('exclude-me')) diff --git a/turbopack/crates/turbo-tasks-fs/examples/hash_glob.rs b/turbopack/crates/turbo-tasks-fs/examples/hash_glob.rs index 84cb1e0e38ba..fdbf861048d5 100644 --- a/turbopack/crates/turbo-tasks-fs/examples/hash_glob.rs +++ b/turbopack/crates/turbo-tasks-fs/examples/hash_glob.rs @@ -98,6 +98,9 @@ async fn hash_glob_result(result: Vc) -> Result> { #[turbo_tasks::function] async fn hash_file(file_path: FileSystemPath) -> Result> { + let Ok(file_path) = file_path.realpath().await? else { + return Ok(empty_string()); + }; let content = file_path.read().await?; Ok(match &*content { FileContent::Content(file) => hash_content(&mut file.read()), diff --git a/turbopack/crates/turbo-tasks-fs/src/disk.rs b/turbopack/crates/turbo-tasks-fs/src/disk.rs index c9b195f1d026..8dd768145de5 100644 --- a/turbopack/crates/turbo-tasks-fs/src/disk.rs +++ b/turbopack/crates/turbo-tasks-fs/src/disk.rs @@ -482,6 +482,22 @@ impl DiskFileSystem { &self.inner.root } + #[cfg(debug_assertions)] + async fn ensure_path_is_realpath(&self, operation: &str, path: &Path) -> Result<()> { + if let Ok(realpath) = retry_blocking(|| fs_err::canonicalize(path)) + .instrument(tracing::info_span!("realpath for filesystem read", name = ?path)) + .concurrency_limited(&self.inner.read_semaphore) + .await + && realpath != path + { + anyhow::bail!( + "{operation} called with unresolved path {path:?}; resolve it to {realpath:?} \ + first" + ); + } + Ok(()) + } + pub fn invalidate(&self) { self.inner.invalidate(); } @@ -797,7 +813,12 @@ impl FileSystem for DiskFileSystem { .concurrency_limited(&self.inner.read_semaphore) .await { - Ok(file) => FileContent::new(file), + Ok(file) => { + #[cfg(debug_assertions)] + self.ensure_path_is_realpath("read_file", &full_path) + .await?; + FileContent::new(file) + } Err(e) if e.kind() == ErrorKind::NotFound || e.kind() == ErrorKind::InvalidFilename => { FileContent::NotFound } @@ -823,7 +844,11 @@ impl FileSystem for DiskFileSystem { .concurrency_limited(&self.inner.read_semaphore) .await { - Ok(dir) => dir, + Ok(dir) => { + #[cfg(debug_assertions)] + self.ensure_path_is_realpath("read_dir", &full_path).await?; + dir + } Err(e) if e.kind() == ErrorKind::NotFound || e.kind() == ErrorKind::NotADirectory @@ -1719,6 +1744,8 @@ mod tests { use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage}; use super::extract_effects_operation; + #[cfg(all(unix, debug_assertions))] + use crate::{DirectoryContent, FileContent, RawDirectoryContent}; use crate::{ DiskFileSystem, FileSystem, FileSystemEntryType, FileSystemPath, LinkContent, LinkTarget, RealPathErrorType, WriteLinkContent, WriteLinkTarget, WriteLinkTargetType, @@ -1865,6 +1892,87 @@ mod tests { Ok(()) } + #[cfg(all(unix, debug_assertions))] + #[turbo_tasks::function(operation, root)] + async fn assert_read_realpath_operation(root_path: FileSystemPath) -> anyhow::Result<()> { + let unresolved_dir = root_path.join("alias/child")?; + let resolved_dir = unresolved_dir + .realpath() + .await? + .expect("the linked directory should resolve"); + + assert_ne!(unresolved_dir, resolved_dir); + let error = unresolved_dir + .read_dir() + .await + .expect_err("a directory read through a symlinked parent must be rejected"); + let message = format!("{error:#}"); + assert!(message.contains("alias/child")); + assert!(message.contains("real/child")); + assert!(matches!( + &*resolved_dir.read_dir().await?, + DirectoryContent::Entries(entries) if entries.contains_key(&rcstr!("data.txt")) + )); + + assert!(matches!( + &*root_path.join("file-alias")?.raw_read_dir().await?, + RawDirectoryContent::NotFound + )); + + let unresolved_file = unresolved_dir.join("data.txt")?; + let resolved_file = unresolved_file + .realpath() + .await? + .expect("the linked file should resolve"); + assert_ne!(unresolved_file, resolved_file); + let error = unresolved_file + .read() + .await + .expect_err("a file read through a symlinked parent must be rejected"); + let message = format!("{error:#}"); + assert!(message.contains("alias/child/data.txt")); + assert!(message.contains("real/child/data.txt")); + assert!(matches!( + &*resolved_file.read().await?, + FileContent::Content(_) + )); + + Ok(()) + } + + #[cfg(all(unix, debug_assertions))] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_reads_require_realpath() { + use std::os::unix::fs::symlink; + + let scratch = tempfile::tempdir().unwrap(); + let path = scratch.path().to_owned(); + create_dir_all(path.join("real/child")).unwrap(); + File::create_new(path.join("real/child/data.txt")).unwrap(); + symlink("real", path.join("alias")).unwrap(); + symlink("real/child/data.txt", path.join("file-alias")).unwrap(); + + let root = canonicalize_to_rcstr(&path).unwrap(); + let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new( + BackendOptions::default(), + noop_backing_storage(), + )); + + tt.run_once(async move { + let fs = disk_file_system_operation(root) + .resolve() + .strongly_consistent() + .await?; + let root_path = disk_file_system_root(fs); + assert_read_realpath_operation(root_path) + .read_strongly_consistent() + .await?; + anyhow::Ok(()) + }) + .await + .unwrap(); + } + /// `read_link` never looks at the target, so a dangling link still reads back as a valid /// [`LinkContent::Link`]. Resolving it reports the missing target. #[cfg(unix)] diff --git a/turbopack/crates/turbo-tasks-fs/src/read_glob.rs b/turbopack/crates/turbo-tasks-fs/src/read_glob.rs index ac3dc60d6973..d9c2c78dcb57 100644 --- a/turbopack/crates/turbo-tasks-fs/src/read_glob.rs +++ b/turbopack/crates/turbo-tasks-fs/src/read_glob.rs @@ -16,27 +16,42 @@ pub struct ReadGlobResult { pub inner: FxHashMap>, } -/// Reads matches of a glob pattern. Symlinks are not resolved (and returned as-is) +async fn resolve_glob_root(directory: FileSystemPath) -> Result { + Ok(directory + .realpath() + .await? + .unwrap_or_else(|_| directory.clone())) +} + +/// Reads matches of a glob pattern. +/// +/// Directories are resolved before physical enumeration, but [`DirectoryEntry`] paths in the +/// result remain logical paths rooted at the supplied `directory`. Consumers must resolve returned +/// paths before filesystem access when they need the physical path or its symlink chain. /// /// DETERMINISM: Result is in random order. Either sort result or do not depend /// on the order. #[turbo_tasks::function(fs)] pub async fn read_glob(directory: FileSystemPath, glob: Vc) -> Result> { - read_glob_internal("", directory, glob).await + let root = directory.clone(); + let directory = resolve_glob_root(directory).await?; + read_glob_internal("", &root, directory, glob).await } #[turbo_tasks::function(fs)] async fn read_glob_inner( prefix: RcStr, + root: FileSystemPath, directory: FileSystemPath, glob: Vc, ) -> Result> { - read_glob_internal(&prefix, directory, glob).await + read_glob_internal(&prefix, &root, directory, glob).await } -// The `prefix` represents the relative directory path where symlinks are not resolve. +// The `prefix` represents the relative directory path where symlinks are not resolved. async fn read_glob_internal( prefix: &str, + root: &FileSystemPath, directory: FileSystemPath, glob: Vc, ) -> Result> { @@ -58,7 +73,7 @@ async fn read_glob_internal( if glob_value.can_match_in_directory(&entry_path) { result.inner.insert( segment.clone(), - read_glob_inner(entry_path, path.clone(), glob) + read_glob_inner(entry_path, root.clone(), path.clone(), glob) .to_resolved() .await?, ); @@ -75,13 +90,22 @@ async fn read_glob_internal( format!("{prefix}/{segment}").into() }; + let output_path = root.join(&entry_path)?; + let output_entry = match entry { + DirectoryEntry::File(_) => DirectoryEntry::File(output_path), + DirectoryEntry::Directory(_) => DirectoryEntry::Directory(output_path), + DirectoryEntry::Symlink(_) => DirectoryEntry::Symlink(output_path), + DirectoryEntry::Other(_) => DirectoryEntry::Other(output_path), + DirectoryEntry::Error(error) => DirectoryEntry::Error(error.clone()), + }; + match entry { DirectoryEntry::File(_) => { - handle_file(&mut result, &entry_path, segment, entry); + handle_file(&mut result, &entry_path, segment, &output_entry); } DirectoryEntry::Directory(path) => { // Add the directory to `results` if it is a whole match of the glob - handle_file(&mut result, &entry_path, segment, entry); + handle_file(&mut result, &entry_path, segment, &output_entry); // Recursively handle the directory handle_dir(&mut result, entry_path, segment, path).await?; } @@ -91,7 +115,7 @@ async fn read_glob_internal( if let LinkContent::Link { target } = &*link_content { let Ok(realpath) = target.file_system_path().realpath().await? else { // Preserve unresolvable symlinks that match the glob. - handle_file(&mut result, &entry_path, segment, entry); + handle_file(&mut result, &entry_path, segment, &output_entry); continue; }; if matches!(*realpath.get_type().await?, FileSystemEntryType::Directory) @@ -100,11 +124,12 @@ async fn read_glob_internal( check_symlink_directory_recursion(path, &realpath)?; // Add the directory to `results` if it is a whole match of the glob - handle_file(&mut result, &entry_path, segment, entry); - // Recursively handle the directory - handle_dir(&mut result, entry_path, segment, path).await?; + handle_file(&mut result, &entry_path, segment, &output_entry); + // Enumerate the resolved target while preserving logical paths in + // the glob result. + handle_dir(&mut result, entry_path, segment, &realpath).await?; } else { - handle_file(&mut result, &entry_path, segment, entry); + handle_file(&mut result, &entry_path, segment, &output_entry); } } } @@ -157,15 +182,17 @@ fn check_symlink_directory_recursion( /// Traverses all directories that match the given `glob`. /// -/// This ensures that the calling task will be invalidated -/// whenever the directories or contents of the directories change, -/// but unlike read_glob doesn't accumulate data. +/// This ensures that the calling task will be invalidated whenever the directories or contents of +/// the directories change, but unlike [`read_glob`] doesn't accumulate data. Directories are +/// resolved before physical enumeration, including the initial `directory` and symlinks discovered +/// during traversal. #[turbo_tasks::function(fs)] pub async fn track_glob( directory: FileSystemPath, glob: Vc, include_dot_files: bool, ) -> Result> { + let directory = resolve_glob_root(directory).await?; track_glob_internal("", directory, glob, include_dot_files).await } @@ -266,7 +293,7 @@ pub mod tests { use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage}; use crate::{ - DirectoryEntry, DiskFileSystem, FileContent, FileSystem, FileSystemPath, + DirectoryEntry, DiskFileSystem, FileContent, FileSystem, FileSystemPath, ReadGlobResult, glob::{Glob, GlobOptions}, }; @@ -377,10 +404,16 @@ pub mod tests { let inner_sub_dir = &*inner_sub.inner.get("dir").unwrap().await?; assert_eq!( inner_sub_dir.results, - HashMap::from_iter([( - "index.js".into(), - DirectoryEntry::File(root.join("sub/dir/index.js")?), - )]) + HashMap::from_iter([ + ( + "index.js".into(), + DirectoryEntry::File(root.join("sub/dir/index.js")?), + ), + ( + "dead.js".into(), + DirectoryEntry::Symlink(root.join("sub/dir/dead.js")?), + ), + ]) ); assert_eq!(inner_sub_dir.inner.len(), 0); @@ -395,10 +428,16 @@ pub mod tests { let inner_sub_dir = &*inner_sub.inner.get("dir-chain").unwrap().await?; assert_eq!( inner_sub_dir.results, - HashMap::from_iter([( - "index.js".into(), - DirectoryEntry::File(root.join("sub/dir-chain/index.js")?), - )]) + HashMap::from_iter([ + ( + "index.js".into(), + DirectoryEntry::File(root.join("sub/dir-chain/index.js")?), + ), + ( + "dead.js".into(), + DirectoryEntry::Symlink(root.join("sub/dir-chain/dead.js")?), + ), + ]) ); assert_eq!(inner_sub_dir.inner.len(), 0); @@ -490,6 +529,7 @@ pub mod tests { .unwrap() .write_all(b"dir index") .unwrap(); + symlink(dir.join("missing.js"), dir.join("dead.js")).unwrap(); symlink(&dir, path.join("sub/dir")).unwrap(); let dir_link = path.join("dir-link"); symlink(&dir, &dir_link).unwrap(); @@ -564,6 +604,134 @@ pub mod tests { Ok(()) } + #[turbo_tasks::function(operation, root)] + async fn read_glob_from_operation( + path: RcStr, + directory: RcStr, + glob: RcStr, + ) -> anyhow::Result> { + let root = disk_file_system_root_operation(path) + .read_strongly_consistent() + .await?; + Ok(root + .join(&directory)? + .read_glob(Glob::new(glob, GlobOptions::default()))) + } + + #[turbo_tasks::function(operation, root)] + async fn track_glob_from_operation( + path: RcStr, + directory: RcStr, + glob: RcStr, + ) -> anyhow::Result> { + let root = disk_file_system_root_operation(path) + .read_strongly_consistent() + .await?; + Ok(root + .join(&directory)? + .track_glob(Glob::new(glob, GlobOptions::default()), false)) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn glob_roots_resolve_symlink_parents() { + let scratch = tempfile::tempdir().unwrap(); + let path = scratch.path(); + let target = path.join("target/inner/path"); + std::fs::create_dir_all(&target).unwrap(); + File::create_new(target.join("file.txt")) + .unwrap() + .write_all(b"initial") + .unwrap(); + std::fs::create_dir_all(path.join("path/to")).unwrap(); + symlink(path.join("target"), path.join("path/to/symlink")).unwrap(); + + let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new( + BackendOptions::default(), + noop_backing_storage(), + )); + let disk_root: RcStr = path.to_str().unwrap().into(); + tt.run_once(async move { + let root = disk_file_system_root_operation(disk_root.clone()) + .read_strongly_consistent() + .await?; + let logical_base = root.join("path/to/symlink/inner/path")?; + + let initial = read_glob_from_operation( + disk_root.clone(), + rcstr!("path/to/symlink/inner/path"), + rcstr!("*"), + ) + .read_strongly_consistent() + .await?; + assert_eq!( + initial.results.get("file.txt"), + Some(&DirectoryEntry::File(logical_base.join("file.txt")?)) + ); + + let wildcard = read_glob_from_operation( + disk_root.clone(), + rcstr!(""), + rcstr!("path/to/*/inner/path/*"), + ) + .read_strongly_consistent() + .await?; + let path_result = wildcard.inner.get("path").unwrap().await?; + let to_result = path_result.inner.get("to").unwrap().await?; + let symlink_result = to_result.inner.get("symlink").unwrap().await?; + let inner_result = symlink_result.inner.get("inner").unwrap().await?; + let final_result = inner_result.inner.get("path").unwrap().await?; + assert_eq!( + final_result.results.get("file.txt"), + Some(&DirectoryEntry::File(logical_base.join("file.txt")?)) + ); + + let initial_tracking = track_glob_from_operation( + disk_root.clone(), + rcstr!("path/to/symlink/inner/path"), + rcstr!("*"), + ) + .read_strongly_consistent() + .await?; + let wildcard_tracking = track_glob_from_operation( + disk_root.clone(), + rcstr!(""), + rcstr!("path/to/*/inner/path/*"), + ) + .read_strongly_consistent() + .await?; + + read_strongly_consistent_and_apply_effects( + extract_effects_operation(write( + root.join("target/inner/path/file.txt")?, + rcstr!("updated"), + )), + |e| e, + ) + .await?; + + let initial_tracking_after = track_glob_from_operation( + disk_root.clone(), + rcstr!("path/to/symlink/inner/path"), + rcstr!("*"), + ) + .read_strongly_consistent() + .await?; + let wildcard_tracking_after = + track_glob_from_operation(disk_root, rcstr!(""), rcstr!("path/to/*/inner/path/*")) + .read_strongly_consistent() + .await?; + + assert!(!ReadRef::ptr_eq(&initial_tracking, &initial_tracking_after)); + assert!(!ReadRef::ptr_eq( + &wildcard_tracking, + &wildcard_tracking_after + )); + anyhow::Ok(()) + }) + .await + .unwrap(); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn track_glob_invalidations() { let scratch = tempfile::tempdir().unwrap(); diff --git a/turbopack/crates/turbopack-core/src/module_graph/binding_usage_info.rs b/turbopack/crates/turbopack-core/src/module_graph/binding_usage_info.rs index bd808340f541..8c1d110f2924 100644 --- a/turbopack/crates/turbopack-core/src/module_graph/binding_usage_info.rs +++ b/turbopack/crates/turbopack-core/src/module_graph/binding_usage_info.rs @@ -23,6 +23,18 @@ pub struct UsedExportsMap(FxHashMap>, ModuleExportUsa #[turbo_tasks::value(transparent, cell = "keyed")] pub struct ExportCircuitBreakers(FxHashSet>>); +/// Modules that are read through a *partial namespace object* — an +/// [`ExportUsage::PartialNamespaceObject`] edge points at them. +/// +/// The used export names are still known individually (that is why the usage stays +/// [`ModuleExportUsageInfo::Exports`]), but the reads went through a namespace value: a namespace +/// binding's member reads or destructuring, or the object a dynamic `import()` resolves to. Some of +/// those reads are lowered to direct named accesses and some are not, and this set does not +/// distinguish them — so a consumer that wants to rename the module's export keys has to assume the +/// original names may still be read somewhere, and leave them alone. +#[turbo_tasks::value(transparent, cell = "keyed")] +pub struct PartialNamespaceModules(FxHashSet>>); + #[turbo_tasks::value] #[derive(Clone, Default, Debug)] pub struct BindingUsageInfo { @@ -32,6 +44,7 @@ pub struct BindingUsageInfo { used_exports: ResolvedVc, export_circuit_breakers: ResolvedVc, + partial_namespace_modules: ResolvedVc, } #[turbo_tasks::value(transparent)] @@ -42,6 +55,9 @@ pub struct ModuleExportUsage { pub export_usage: ResolvedVc, // Whether this module exists in an import cycle and has been selected to break the cycle. pub is_circuit_breaker: bool, + /// Whether this module is read through a namespace value somewhere, which means one of those + /// reads may still use an original export name. See [`PartialNamespaceModules`]. + pub namespace_object_may_escape: bool, } #[turbo_tasks::value_impl] impl ModuleExportUsage { @@ -50,6 +66,7 @@ impl ModuleExportUsage { Ok(Self { export_usage: ModuleExportUsageInfo::all().to_resolved().await?, is_circuit_breaker: true, + namespace_object_may_escape: true, } .cell()) } @@ -78,9 +95,12 @@ impl BindingUsageInfo { bail!("export usage not found for module: {ident:?}"); }; + let namespace_object_may_escape = + self.partial_namespace_modules.contains_key(&module).await?; Ok(ModuleExportUsage { export_usage: (*exports).clone().resolved_cell(), is_circuit_breaker, + namespace_object_may_escape, } .cell()) } @@ -108,6 +128,7 @@ pub async fn compute_binding_usage_info( async move { let mut used_exports = FxHashMap::<_, ModuleExportUsageInfo>::default(); + let mut partial_namespace_modules = FxHashSet::default(); #[cfg(debug_assertions)] let mut debug_unused_references_name = FxHashSet::<( ResolvedVc>, @@ -246,6 +267,14 @@ pub async fn compute_binding_usage_info( let entry = used_exports.entry(target); let is_first_visit = matches!(entry, Entry::Vacant(_)); + if matches!( + &ref_data.binding_usage.export, + ExportUsage::PartialNamespaceObject(_) + ) { + // `target` is read through a namespace value. We know which names are used, but + // not that every read of them was lowered to a direct named access. + partial_namespace_modules.insert(target); + } if entry.or_default().add(&ref_data.binding_usage.export) || is_first_visit { // First visit, or the used exports changed. This can cause more imports to get // used downstream. @@ -336,6 +365,7 @@ pub async fn compute_binding_usage_info( unused_references_edges, used_exports: ResolvedVc::cell(used_exports), export_circuit_breakers: ResolvedVc::cell(export_circuit_breakers), + partial_namespace_modules: ResolvedVc::cell(partial_namespace_modules), } .cell()) } diff --git a/turbopack/crates/turbopack-core/src/resolve/pattern.rs b/turbopack/crates/turbopack-core/src/resolve/pattern.rs index 36311f3add4b..c5d0104f4a5a 100644 --- a/turbopack/crates/turbopack-core/src/resolve/pattern.rs +++ b/turbopack/crates/turbopack-core/src/resolve/pattern.rs @@ -11,7 +11,8 @@ use rustc_hash::{FxHashMap, FxHashSet}; use tracing::Instrument; use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{ - NonLocalValue, TaskInput, ValueToString, Vc, debug::ValueDebugFormat, trace::TraceRawVcs, + NonLocalValue, ReadRef, TaskInput, ValueToString, Vc, debug::ValueDebugFormat, + trace::TraceRawVcs, }; use turbo_tasks_fs::{ FileSystemEntryType, FileSystemPath, LinkContent, RawDirectoryContent, RawDirectoryEntry, @@ -1511,14 +1512,29 @@ impl PatternMatch { #[derive(Debug)] pub struct PatternMatches(Vec); +/// Reads the directory `path` points at, enumerating it through its realpath. +/// +/// Callers keep the original logical `path` when constructing [`PatternMatch`] values, so symlinks +/// stay visible in the results while the directory itself is never read through a symlinked +/// parent. Resolving also registers a dependency on the symlink chain, so replacing a link +/// invalidates the enumeration. +/// +/// A path that cannot be resolved (a dangling or cyclic link) is read as-is, which yields +/// [`RawDirectoryContent::NotFound`] just as it did before. +async fn raw_read_dir_resolved(path: &FileSystemPath) -> Result> { + let resolved = path.realpath().await?.unwrap_or_else(|_| path.clone()); + resolved.raw_read_dir().await +} + /// Find all files or directories that match the provided `pattern` with the /// specified `lookup_dir` directory. `prefix` is the already matched part of /// the pattern that leads to the `lookup_dir` directory. When /// `force_in_lookup_dir` is set, leaving the `lookup_dir` directory by /// matching `..` is not allowed. /// -/// Symlinks will not be resolved. It's expected that the caller resolves -/// symlinks when they are interested in that. +/// Symlinks in returned matches are not resolved. Lookup directories are resolved only for +/// physical enumeration; logical paths are retained in [`PatternMatch`] values so callers can +/// resolve and track the symlinks they are interested in. #[turbo_tasks::function] pub async fn read_matches( lookup_dir: FileSystemPath, @@ -1572,7 +1588,7 @@ pub async fn read_matches( lookup_dir.try_join(parent_path) }; if let Some(path) = path_option { - Some(e.insert((path.raw_read_dir().await?, path))) + Some(e.insert((raw_read_dir_resolved(&path).await?, path))) } else { None } @@ -1739,7 +1755,7 @@ pub async fn read_matches( prefix.pop(); prefix.pop(); } - match &*lookup_dir.raw_read_dir().await? { + match &*raw_read_dir_resolved(&lookup_dir).await? { RawDirectoryContent::Entries(map) => { for (key, entry) in map.iter() { match entry { @@ -1917,7 +1933,8 @@ mod tests { use turbo_tasks_fs::{DiskFileSystem, FileSystem}; use super::{ - Pattern, longest_common_prefix, longest_common_suffix, read_matches, split_last_segment, + Pattern, PatternMatch, longest_common_prefix, longest_common_suffix, read_matches, + split_last_segment, }; #[test] @@ -2660,6 +2677,60 @@ mod tests { assert_eq!(split_last_segment("../../a/"), ("../..", "a")); } + #[cfg(all(unix, debug_assertions))] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_read_matches_resolves_lookup_dir_for_enumeration() { + use std::{fs::create_dir_all, os::unix::fs::symlink}; + + let scratch = tempfile::tempdir().unwrap(); + let root = scratch.path(); + create_dir_all(root.join("real")).unwrap(); + std::fs::write(root.join("real/file.js"), "content").unwrap(); + symlink("real", root.join("alias")).unwrap(); + symlink("real/file.js", root.join("file-alias")).unwrap(); + + #[turbo_tasks::function(operation, root)] + async fn operation(disk_root: RcStr) -> anyhow::Result<()> { + let root = DiskFileSystem::new(rcstr!("test"), Vc::cell(disk_root)) + .root() + .owned() + .await?; + let logical_dir = root.join("alias")?; + let matches = read_matches( + logical_dir.clone(), + rcstr!(""), + true, + Pattern::new(Pattern::Dynamic), + ) + .await?; + assert!(matches.iter().any(|m| m + == &PatternMatch::File(rcstr!("file.js"), logical_dir.join("file.js").unwrap(),))); + + let file_probe = read_matches( + root.join("file-alias")?, + rcstr!(""), + true, + Pattern::new(Pattern::Dynamic), + ) + .await?; + assert!(file_probe.is_empty()); + + Ok(()) + } + + let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new( + BackendOptions::default(), + noop_backing_storage(), + )); + let disk_root: RcStr = root.to_str().unwrap().into(); + tt.run_once(async move { + operation(disk_root).read_strongly_consistent().await?; + anyhow::Ok(()) + }) + .await + .unwrap(); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_read_matches() { let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new( diff --git a/turbopack/crates/turbopack-ecmascript/src/chunk/placeable.rs b/turbopack/crates/turbopack-ecmascript/src/chunk/placeable.rs index f1c0e1302e45..417eab76a9be 100644 --- a/turbopack/crates/turbopack-ecmascript/src/chunk/placeable.rs +++ b/turbopack/crates/turbopack-ecmascript/src/chunk/placeable.rs @@ -288,6 +288,38 @@ pub enum EcmascriptExports { #[turbo_tasks::value_impl] impl EcmascriptExports { + /// A view of these exports for a module that *borrows* them from another module, i.e. whose + /// `get_exports` hands out the exports value of some other module verbatim. + /// + /// Export mangling is decided per module: the producing side keys on the module whose code + /// generation emits the export object, and the consuming side on the module it imports from. + /// An exports value shared by two module identities would let those two sides compute + /// different keys for the same export, so a borrowed view is always unmangled — which both + /// sides agree on. + #[turbo_tasks::function] + pub async fn borrowed(self: Vc) -> Result> { + let this = self.await?; + Ok(match &*this { + EcmascriptExports::EsmExports(exports) => { + let exports = exports.await?; + if !exports.mangle_export_names { + return Ok(self); + } + EcmascriptExports::EsmExports( + EsmExports { + exports: exports.exports.clone(), + star_exports: exports.star_exports.clone(), + mangle_export_names: false, + } + .resolved_cell(), + ) + .cell() + } + // Nothing else carries a mangling decision. + _ => self, + }) + } + /// Returns whether this module should be split into separate locals and facade modules. /// /// Splitting is enabled when the module has re-exports (star exports or imported bindings), diff --git a/turbopack/crates/turbopack-ecmascript/src/collect_module.rs b/turbopack/crates/turbopack-ecmascript/src/collect_module.rs index 7cad88609cf4..14393d592d1c 100644 --- a/turbopack/crates/turbopack-ecmascript/src/collect_module.rs +++ b/turbopack/crates/turbopack-ecmascript/src/collect_module.rs @@ -22,11 +22,20 @@ use crate::{ EcmascriptChunkItemContent, EcmascriptChunkPlaceable, EcmascriptExports, ecmascript_chunk_item, }, - references::esm::{EsmExport, EsmExports, Liveness}, + references::esm::{EsmExport, EsmExports, Liveness, mangle::generated_export_key}, runtime_functions::{TURBOPACK_ESM, TURBOPACK_IMPORT}, utils::StringifyJs, }; +/// The single export of the generated collect module. +/// +/// Both the producer below and the read side in `references::emit_collect` resolve the key this is +/// actually *emitted* under through `generated_export_key`, rather than assuming the source name, +/// so the two cannot drift apart. Today that always resolves back to this name — see +/// `get_exports` for why — but wiring it through means mangling starts applying to both sides +/// together the moment it becomes possible, with no further change here. +pub const COLLECT_LIST_EXPORT: RcStr = rcstr!("getList"); + #[turbo_tasks::value] pub struct EcmascriptCollectModule { parent_module: ResolvedVc>, @@ -165,11 +174,20 @@ impl EcmascriptChunkPlaceable for EcmascriptCollectModuleWithChunkGroup { EcmascriptExports::EsmExports( EsmExports { exports: [( - "getList".into(), - EsmExport::LocalBinding(rcstr!("getList"), Liveness::Constant), + COLLECT_LIST_EXPORT, + EsmExport::LocalBinding(COLLECT_LIST_EXPORT, Liveness::Constant), )] .into(), star_exports: vec![], + // This module is code-generated but never referenced in the module graph (it is + // reached through `collected_modules`), so it has no entry in the graph's + // used-export map and `module_export_usage` cannot answer for it — the same class + // of module as the wasm loader and the client-reference proxy, which + // `BindingUsageInfo::used_exports` special-cases by ident with a `TODO fix these + // cases`. Mangling therefore cannot be computed here at all, rather than being + // merely undesirable. Keeping this `false` makes `mangled_export_names` answer + // before it consults the graph; flip it once this module is graph-tracked. + mangle_export_names: false, } .resolved_cell(), ) @@ -204,15 +222,16 @@ impl EcmascriptChunkPlaceable for EcmascriptCollectModuleWithChunkGroup { #[turbo_tasks::function] async fn chunk_item_content( - &self, + self: Vc, chunking_context: Vc>, module_graph: Vc, _async_module_info: Option>, _estimated: bool, ) -> Result> { + let this = self.await?; let chunk_item_id_strategy = chunking_context.chunk_item_id_strategy().await?; - let entries = self + let entries = this .entry_chunk_group .await? .into_iter() @@ -220,7 +239,7 @@ impl EcmascriptChunkPlaceable for EcmascriptCollectModuleWithChunkGroup { let collected_modules = module_graph.collected_modules(); let items = collected_modules - .get(&ResolvedVc::upcast(self.module)) + .get(&ResolvedVc::upcast(this.module)) .await?; let items = items .iter() @@ -262,11 +281,19 @@ impl EcmascriptChunkPlaceable for EcmascriptCollectModuleWithChunkGroup { code += "]);"; code += "function getList() { return data; }"; + // The *key* this is exposed under may be mangled; the local binding keeps its own name. + // `references::emit_collect` resolves the same key for the read side, so both agree. + let export = generated_export_key( + ResolvedVc::upcast(self.to_resolved().await?), + chunking_context, + &COLLECT_LIST_EXPORT, + ) + .await?; + writeln!( code, - "{TURBOPACK_ESM}([ - 'getList', ()=>getList -]);" + "{TURBOPACK_ESM}([\n {}, ()=>getList\n]);", + StringifyJs(&export), )?; Ok(EcmascriptChunkItemContent { diff --git a/turbopack/crates/turbopack-ecmascript/src/lib.rs b/turbopack/crates/turbopack-ecmascript/src/lib.rs index 4a0e8df7976a..748c46fa0dfa 100644 --- a/turbopack/crates/turbopack-ecmascript/src/lib.rs +++ b/turbopack/crates/turbopack-ecmascript/src/lib.rs @@ -242,6 +242,10 @@ pub struct EcmascriptOptions { pub infer_module_side_effects: bool, /// Whether to tree shake unused exports from static CommonJS modules. Defaults to false. pub cjs_tree_shaking: bool, + /// Whether to shorten ("mangle") the export names this module exposes to other modules, to + /// reduce output size. Defaults to false. See + /// `references::esm::mangle::mangled_export_names`. + pub mangle_export_names: bool, /// Whether to scope hoist static CommonJS modules. Defaults to false. pub cjs_scope_hoisting: bool, /// Whether to enable cross-module constant inlining. Defaults to false. diff --git a/turbopack/crates/turbopack-ecmascript/src/module_fragments/side_effects/module.rs b/turbopack/crates/turbopack-ecmascript/src/module_fragments/side_effects/module.rs index 35a35cf43994..5d7428d3d8dc 100644 --- a/turbopack/crates/turbopack-ecmascript/src/module_fragments/side_effects/module.rs +++ b/turbopack/crates/turbopack-ecmascript/src/module_fragments/side_effects/module.rs @@ -134,7 +134,8 @@ impl Module for SideEffectsModule { impl EcmascriptChunkPlaceable for SideEffectsModule { #[turbo_tasks::function] fn get_exports(&self) -> Vc { - self.resolved_as.get_exports() + // Borrowed from another module identity, so it must not carry a mangling decision. + self.resolved_as.get_exports().borrowed() } #[turbo_tasks::function] diff --git a/turbopack/crates/turbopack-ecmascript/src/references/emit_collect.rs b/turbopack/crates/turbopack-ecmascript/src/references/emit_collect.rs index 2b658797d0d7..f141917f117d 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/emit_collect.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/emit_collect.rs @@ -1,8 +1,8 @@ use anyhow::{Result, bail}; use bincode::{Decode, Encode}; use swc_core::{ - ecma::ast::{Expr, Invalid}, - quote, + common::DUMMY_SP, + ecma::ast::{Expr, IdentName, Invalid, MemberExpr, MemberProp}, }; use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{ @@ -28,10 +28,11 @@ use turbopack_resolve::ecmascript::esm_resolve; use crate::{ analyzer::imports::ImportAnnotations, code_gen::{CodeGen, CodeGeneration, IntoCodeGenReference}, - collect_module::EcmascriptCollectModule, + collect_module::{COLLECT_LIST_EXPORT, EcmascriptCollectModule}, create_visitor, references::{ AstPath, + esm::{base::ReferencedAsset, mangle::generated_export_key}, pattern_mapping::{PatternMapping, ResolveType}, removal::RemovalCodeGen, }, @@ -253,14 +254,27 @@ impl CollectReferenceCodeGen { .await?; let mut visitors = Vec::new(); + // The collect module's own `getList` export is mangled like any other, so resolve the key + // it is actually emitted under rather than hard-coding the source name. Both sides ask the + // same module, so they always agree; when that module keeps its original names this is + // just `getList` again. + let export = + match ReferencedAsset::from_resolve_result(self.reference.resolve_reference()).await? { + ReferencedAsset::Some(module) => { + generated_export_key(module, chunking_context, &COLLECT_LIST_EXPORT).await? + } + _ => COLLECT_LIST_EXPORT, + }; + visitors.push(create_visitor!( self.path, visit_mut_expr, |expr: &mut Expr| { - *expr = quote!( - "$v.getList" as Expr, - v: Expr = pm.create_require(Expr::Invalid(Invalid::default())) - ); + *expr = Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: Box::new(pm.create_require(Expr::Invalid(Invalid::default()))), + prop: MemberProp::Ident(IdentName::new(export.as_str().into(), DUMMY_SP)), + }); } )); diff --git a/turbopack/crates/turbopack-ecmascript/src/references/esm/base.rs b/turbopack/crates/turbopack-ecmascript/src/references/esm/base.rs index 5dd2fb8402e0..f5f1ca0bb83a 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/esm/base.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/esm/base.rs @@ -51,6 +51,7 @@ use crate::{ esm::{ EsmExport, export::{all_known_export_names, is_export_missing}, + mangle::generated_export_key, }, util::{SpecifiedChunkingType, throw_module_not_found_expr}, }, @@ -308,7 +309,16 @@ impl ReferencedAsset { Some(ReferencedAssetIdent::Module { namespace_ident: import_source.get_namespace_ident(chunking_context).await?, ctxt: None, - export, + // The target module may emit its exports under shortened keys. This is the + // only place a cross-module export access is materialized, and it resolves + // the same map the producing module uses (see + // `EsmExports::code_generation`), so the two always agree. + export: match &export { + Some(export) => { + Some(generated_export_key(*asset, chunking_context, export).await?) + } + None => None, + }, import_source, }) } diff --git a/turbopack/crates/turbopack-ecmascript/src/references/esm/export.rs b/turbopack/crates/turbopack-ecmascript/src/references/esm/export.rs index 3dc2cc3644d5..a9ab98d89ff5 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/esm/export.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/esm/export.rs @@ -33,7 +33,7 @@ use crate::{ code_gen::{CodeGeneration, CodeGenerationHoistedStmt}, magic_identifier::MAGIC_IDENTIFIER_DEFAULT_EXPORT_ATOM, module_fragments::part::module::EcmascriptModulePartAsset, - references::esm::base::ReferencedAsset, + references::esm::{base::ReferencedAsset, mangle::mangled_export_names}, runtime_functions::{TURBOPACK_DYNAMIC, TURBOPACK_ESM}, utils::module_id_to_lit, }; @@ -533,6 +533,10 @@ pub struct EsmExports { pub exports: FrozenMap, /// Unexpanded `export * from ...` statements (expanded in `expand_star_exports`) pub star_exports: Vec>>, + /// Whether the keys these exports are emitted under may be shortened. Carried with the exports + /// so a module deriving its exports from another (facade, locals, part, rename) inherits it. + /// `mangle::mangled_export_names` decides whether they actually are. + pub mangle_export_names: bool, } /// The expanded version of [`EsmExports`], the `exports` field here includes all exports that could @@ -571,6 +575,9 @@ impl EsmExports { EsmExports { exports: FrozenMap::from(exports), star_exports: vec![module_reference], + // These facades exist so that a host framework can find the wrapped module's + // exports by name, so their keys have to stay as written. + mangle_export_names: false, } .resolved_cell(), ) @@ -697,6 +704,9 @@ impl EsmExports { } let mut getters = Vec::new(); + // The keys this module's exports are emitted under. Consumers resolve the same map for this + // module (see `ReferencedAsset::get_ident_inner`), so both sides always agree. + let mangled_names = mangled_export_names(*module, chunking_context).await?; for (exported, local) in &expanded.exports { let exprs: ExportBinding = match local { EsmExport::Error => ExportBinding::Getter(quote!( @@ -839,7 +849,14 @@ impl EsmExports { getters.push(Some( Expr::Lit(Lit::Str(Str { span: DUMMY_SP, - value: exported.as_str().into(), + // The key this export is emitted under: the mangled one when this module's + // names are shortened, otherwise the original. + value: mangled_names + .as_ref() + .and_then(|names| names.get(exported)) + .unwrap_or(exported) + .as_str() + .into(), raw: None, })) .into(), diff --git a/turbopack/crates/turbopack-ecmascript/src/references/esm/mangle/mod.rs b/turbopack/crates/turbopack-ecmascript/src/references/esm/mangle/mod.rs new file mode 100644 index 000000000000..8e5d0e081e45 --- /dev/null +++ b/turbopack/crates/turbopack-ecmascript/src/references/esm/mangle/mod.rs @@ -0,0 +1,151 @@ +//! Shortening ("mangling") of the export names a module exposes to other modules. +//! +//! A module's export keys usually only exist to link modules together: the producing module emits +//! `{ someVeryLongExportName: () => … }` and every consumer reads +//! `ns["someVeryLongExportName"]`. Both sides are generated by us, so the key can be replaced by a +//! much shorter one — as long as producer and consumer agree, and as long as the name is not +//! observable from user code. +//! +//! [`mangled_export_names`] is the single source of truth for that mapping. Both the producer +//! ([`super::export::EsmExports::code_generation`]) and the consumer +//! ([`super::base::ReferencedAsset`]) ask it for the *target* module's map, so they cannot +//! disagree. It returns an empty map whenever the module has to keep its original names — the +//! same effect as mangling every name to itself, so callers never need to branch on "did mangling +//! even apply here", only look a name up and fall back to itself. +//! +//! Code generation that spells an export access out **as a string** — rather than going through +//! `ReferencedAssetIdent`, which handles this automatically — must resolve its key through +//! [`generated_export_key`]. A hard-coded `["someExport"]` in generated source silently misses as +//! soon as the target module's exports are mangled. + +mod table; + +use anyhow::Result; +use turbo_frozenmap::FrozenMap; +use turbo_rcstr::RcStr; +use turbo_tasks::{ResolvedVc, Vc}; +use turbopack_core::{ + chunk::ChunkingContext, module_graph::binding_usage_info::ModuleExportUsageInfo, +}; + +use self::table::shorten_to_unique_names; +use crate::chunk::{EcmascriptChunkPlaceable, EcmascriptExports}; + +/// A module's export name mapping: original export name -> the key actually used in the output. +/// +/// `None` means the module is not eligible for mangling at all and keeps every original name (see +/// [`mangled_export_names`] for the conditions). `Some` covers only the exports that were actually +/// *renamed* — an export that kept its own name because it was already short enough, or because it +/// lost every hash collision, is not a key in the map. So a missing key always means "use the +/// original name", and callers that only want the correct output key can ignore the distinction +/// entirely (see [`generated_export_key`]); the `canMangle` reporting in +/// [`crate::references::exports_info`] is the one caller that needs to tell "not eligible" from +/// "eligible but unchanged", which is exactly what the `Option` gives it. +#[turbo_tasks::value(transparent)] +pub struct MangledExportNames(pub Option>); + +/// Computes the mangled export names of `module`, or `None` when its exports must keep their +/// original names. +/// +/// Both the producing and the consuming side call this for the same `module`, which is what +/// guarantees they agree. The mapping covers the module's *used* exports: unused exports are not +/// emitted at all, so giving them names would only make the remaining names longer. +/// +/// A module keeps its original names when any of these hold: +/// +/// 1. mangling is disabled for the module that owns these exports, +/// 2. its export usage is [`ModuleExportUsageInfo::All`] — a namespace import, a computed property +/// access, an unresolvable `export *`, or a module referenced from outside the module graph +/// (entries, which are seeded with `All`). This is what a plain `import * as ns` reaches in +/// practice, whether or not the namespace object escapes: the reads are not enumerated, so the +/// usage widens to `All`. Note that widening propagates, so splitting such a module into a +/// facade and a locals module does not rescue the locals module, +/// 3. it is read through a namespace value whose used names *are* known — in practice a dynamic +/// `import()` with an enumerated export list (`import(/* webpackExports: [...] */ "…")`). We +/// know which names are used, but not that every read of them was lowered to a direct named +/// access, so an original name may still be read by user code, +/// 4. its exports are not statically known ECMAScript exports, or contain dynamic re-exports. +/// +/// None of these depend on the individual export, so "is `e` a candidate for mangling" is exactly +/// "is this `Some`, and is `e` used" — the per-name step (hashing into a short identifier) never +/// itself excludes a name. +#[turbo_tasks::function] +pub async fn mangled_export_names( + module: ResolvedVc>, + chunking_context: Vc>, +) -> Result> { + // (4) Only statically known ESM exports can be renamed. + let EcmascriptExports::EsmExports(exports) = *module.get_exports().await? else { + return Ok(Vc::cell(None)); + }; + + // (1) Disabled for the module these exports belong to. Deliberately independent of whether + // this build minifies at all: mangling and minification are two separate concerns, and a + // caller may want either without the other. + if !exports.await?.mangle_export_names { + return Ok(Vc::cell(None)); + } + + let usage = chunking_context + .module_export_usage(*ResolvedVc::upcast(module)) + .await?; + + // (3) An original name may still be read through a namespace value. + if usage.namespace_object_may_escape { + return Ok(Vc::cell(None)); + } + + // (2) We don't know which exports are used, so we don't know that all uses are ours. + let usage_info = usage.export_usage.await?; + let ModuleExportUsageInfo::Exports(used) = &*usage_info else { + return Ok(Vc::cell(None)); + }; + if used.is_empty() { + return Ok(Vc::cell(None)); + } + + // (4) `export * from "./some-dynamic-cjs"` is resolved at runtime by property access on the + // original names, and a used set that resolves to no actual export at all leaves nothing to + // mangle. + let expanded = exports.expand_exports(*usage.export_usage).await?; + if !expanded.dynamic_exports.is_empty() || expanded.exports.is_empty() { + return Ok(Vc::cell(None)); + } + + // Every emitted export is mangled, `default` and `__esModule` included. + // + // Neither is a runtime contract *as an export name*. The runtime paths that look `'default'` up + // by name — the CommonJS interop in `esmNamespaceObject` and the proxy traps behind + // `ensureDynamicExports` — only apply to CommonJS targets and to modules with dynamic + // re-exports, and both already keep their original names via the checks above. `__esModule` is + // how a CommonJS module signals interop *to* ESM, so an ESM module that happens to export that + // name is just an ordinary export. `__esModule` still cannot be *assigned* to some other + // export, because the runtime defines that property itself; see `RESERVED_KEYS` in `table`. + Ok(Vc::cell(Some(shorten_to_unique_names( + expanded.exports.keys(), + )))) +} + +/// The key that generated code has to use to read `export` from `module` — the mangled key when +/// the module's exports are mangled, and `export` itself otherwise. +/// +/// **Every** piece of code generation that materializes an export access as a string has to get its +/// key from here, or the access will miss when the module is mangled. That includes generated +/// source text (`__turbopack_require__(id)["default"](…)` and friends) as well as AST built by +/// hand. Code that goes through `ReferencedAssetIdent` (see `super::base`) is already covered. +/// +/// `module` must be the module that actually *produces* the export. For a re-export, resolve to the +/// producing module first (as `ReferencedAsset::get_ident_inner` does), because each module mangles +/// its own keys independently. +pub async fn generated_export_key( + module: ResolvedVc>, + chunking_context: Vc>, + export: &RcStr, +) -> Result { + let names = mangled_export_names(*module, chunking_context).await?; + Ok(names + .as_ref() + .and_then(|names| names.get(export)) + .cloned() + .unwrap_or_else(|| export.clone())) +} diff --git a/turbopack/crates/turbopack-ecmascript/src/references/esm/mangle/table.rs b/turbopack/crates/turbopack-ecmascript/src/references/esm/mangle/table.rs new file mode 100644 index 000000000000..baa14b718759 --- /dev/null +++ b/turbopack/crates/turbopack-ecmascript/src/references/esm/mangle/table.rs @@ -0,0 +1,655 @@ +//! Deterministic assignment of short, valid JS identifiers to a set of names. +//! +//! Used to shorten module export keys: the keys only exist to link modules together, so as long as +//! producer and consumer agree on the same short key, the original (possibly very long) name never +//! has to appear in the output. +//! +//! The mapping is built so that names stay **stable** across unrelated changes — a plain sequential +//! assignment (`a`, `b`, `c`, …) would renumber everything whenever a name is added or removed. +//! Instead each name is hashed into a table of all valid identifiers of the chosen length, and +//! collisions are resolved by open addressing (take the next free bucket, wrapping around). With +//! many collisions this degrades to sequential assignment, but in the common case a name's short +//! form only depends on the name itself and the size of the table. +//! +//! The assigned names are only ever emitted as **property keys** — object-literal keys in the +//! generated export table, and bracket-access strings at the consuming side. They are never used as +//! bare binding identifiers (the merged / scope-hoisted path refers to the module's own local +//! variables, not to these keys), so a name that happens to spell a reserved word like `if` or `in` +//! is perfectly legal. It is avoided anyway — see `RESERVED_KEYS` in the parent module — because a +//! downstream minifier that folds `ns["name"]` into the shorter `ns.name` typically only does so +//! for a non-reserved identifier, so a keyword-shaped key never gets that benefit. + +use rustc_hash::FxHashSet; +use turbo_frozenmap::FrozenMap; +use turbo_rcstr::{RcStr, rcstr}; +use turbo_tasks_hash::hash_xxh3_hash64; + +/// Characters that may start an identifier (i.e. no digits). +const FIRST_CHARS: &[u8; 54] = b"_$ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +/// Characters that may appear in an identifier after the first character. +const REST_CHARS: &[u8; 64] = b"_$0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +/// The name assigned when a module has exactly one export to mangle. +/// +/// Always picking the same character compresses better than hashing would: `f` is the most common +/// character in JS keywords (`if`, `for`, `function`), and every single-export module in the graph +/// then emits the same `.f` / `.f()` byte sequences, which gzip's back-references pick up across +/// the whole bundle. The cost is that going from one export to two renames this one; that churn is +/// accepted deliberately in exchange for the compression. +const SINGLE_ITEM_IDENTIFIER: RcStr = rcstr!("f"); + +/// Keys that are never handed out, for two different reasons. +/// +/// The reserved words are legal as quoted keys, but a minifier will not fold `ns["if"]` into +/// `ns.if`, so handing one out costs bytes where any other key of the same length saves them. Only +/// two- and three-character words are listed: no single character is a reserved word, and by four +/// characters the table holds 200k+ buckets, so losing a name there is irrelevant. +/// +/// `__esModule` is a correctness case instead: `esm()` in the runtime does +/// `defineProp(exports, '__esModule', …)` for every ESM module, so that property is on the exports +/// object whatever the module's own exports are called, and an assigned key would collide with it. +/// (`default` needs no such treatment once it is mangled: nothing then emits a property under that +/// name, and the runtime paths that read it by name only see modules that keep their original +/// names.) +/// +/// [`reserved_in_table`] has to be kept in step with this list — there is a test for that. +const RESERVED_KEYS: &[&str] = &[ + // 2-character reserved words + "do", + "if", + "in", + // 3-character reserved words. `let` counts: the output is a module, and modules are strict. + "for", + "let", + "new", + "try", + "var", + // Defined on every module's exports object by the runtime, whatever the module's own exports + // are called, so an assigned key must not land on it. + "__esModule", +]; + +/// How many of [`RESERVED_KEYS`] occupy a bucket in a table of `len`-character identifiers. A key +/// only takes a bucket once the table is wide enough to hold it, so this is a running count by +/// length: nothing at one character, the three two-letter words at two, all eight reserved words +/// from three, and `__esModule` from ten. +const fn reserved_in_table(len: u32) -> u64 { + match len { + 0 | 1 => 0, + 2 => 3, + 3..=9 => 8, + _ => 9, + } +} + +/// The number of distinct values encodable in at most `len` characters, i.e. the capacity of the +/// table for that length. +/// +/// There are `54 * 64^(i-1)` strings of length `i` — the first character comes from the 54 that may +/// start an identifier, every later one from all 64 — so this is that summed over `1..=len`: +/// +/// ```text +/// capacity(len) = 54 * (64^len - 1) / 63 +/// ``` +/// +/// Precomputed rather than evaluated on the fly, because [`decode_js_identifier`] needs it for +/// every name it decodes and the formula costs a `pow` and a multiply. +/// [`capacity_for_len_matches_the_formula`] keeps the table honest. From `len == 11` the true value +/// exceeds `u64::MAX` (`54 * 64^10` alone is ≈ 6.2e19 against `u64::MAX`'s ≈ 1.8e19), so it +/// saturates there; no real export table comes anywhere near needing that many characters. +fn capacity_for_len(len: u32) -> u64 { + CAPACITY_FOR_LEN + .get(len as usize) + .copied() + .unwrap_or(u64::MAX) +} + +/// See [`capacity_for_len`]. +const CAPACITY_FOR_LEN: [u64; 11] = [ + 0, + 54, + 3_510, + 224_694, + 14_380_470, + 920_350_134, + 58_902_408_630, + 3_769_754_152_374, + 241_264_265_751_990, + 15_440_913_008_127_414, + 988_218_432_520_154_550, +]; + +/// Encodes a value to a valid JS identifier. Always returns at least one character. +/// +/// Values are partitioned by the length of their encoding: the lowest [`FIRST_CHARS`]`.len()` +/// values are the one-character strings, the next `54 * 64` are the two-character ones, and so on. +/// Peeling those groups off in order turns `value` into a plain index within its own length, which +/// is then written out most-significant character first. +fn encode_js_identifier(mut value: u64) -> String { + let mut len = 1usize; + let mut in_this_len = FIRST_CHARS.len() as u64; + while value >= in_this_len { + value -= in_this_len; + len += 1; + in_this_len = in_this_len.saturating_mul(REST_CHARS.len() as u64); + } + + let mut result = vec![0u8; len]; + // Fill from the tail, so the last character is the least significant digit. + for slot in result[1..].iter_mut().rev() { + *slot = REST_CHARS[(value % REST_CHARS.len() as u64) as usize]; + value /= REST_CHARS.len() as u64; + } + // Whatever is left is below `FIRST_CHARS.len()`, by construction of the length groups above. + result[0] = FIRST_CHARS[value as usize]; + + // SAFETY: FIRST_CHARS and REST_CHARS only contain ASCII + unsafe { String::from_utf8_unchecked(result) } +} + +/// Decodes an identifier back to the value [`encode_js_identifier`] would encode. +/// +/// Returns `None` only when the string contains a character outside the alphabets, or is too long +/// to be a `u64`. Every other non-empty string is the encoding of exactly one value: accumulating +/// `value * base + digit` and then adding the values taken by all shorter strings means a trailing +/// `_` is a digit like any other rather than a leading zero, so there are no degenerate encodings +/// to reject. That bijection is what lets an existing short export name keep itself without ever +/// colliding with a name assigned to something else. +/// +/// Panics on an empty string: export names are never empty, so that would be a caller bug rather +/// than an un-decodable name. +fn decode_js_identifier(s: &str) -> Option { + let bytes = s.as_bytes(); + let (&first, rest) = bytes + .split_first() + .expect("identifiers are never empty, so neither are export names"); + + let mut value = FIRST_CHARS.iter().position(|&c| c == first)? as u64; + for &b in rest { + let digit = REST_CHARS.iter().position(|&c| c == b)? as u64; + value = value + .checked_mul(REST_CHARS.len() as u64)? + .checked_add(digit)?; + } + + // Shift past every value that belongs to a shorter encoding. + value.checked_add(capacity_for_len(s.len() as u32 - 1)) +} + +/// The bucket `name` occupies in a table of `len`-character identifiers, if it occupies one: the +/// name has to fit, and it has to be a name the encoding could itself produce. +fn bucket_of(name: &str, len: u32) -> Option { + if name.len() as u32 > len { + return None; + } + decode_js_identifier(name) +} + +/// Assigns a short, unique identifier to each of `names`, deterministically. +/// +/// The returned map only has an entry for a name that actually changed — a name that already fit +/// and kept itself is simply absent, so a caller must treat a missing key as "keep the original +/// name", not as "not eligible" (that distinction, when it matters, has to come from elsewhere; +/// see the doc comment on [`super::MangledExportNames`]). +/// +/// [`RESERVED_KEYS`] are never handed out, even though they are not themselves assigned a short +/// name. +/// +/// A module with a single export to mangle is special-cased to [`SINGLE_ITEM_IDENTIFIER`], so that +/// every such module in the graph emits the same key and compresses together. +/// +/// The table is sized to the smallest identifier length that can hold every name that needs a +/// bucket, and assignment happens in two passes: +/// +/// 1. Every name that is *already* a valid identifier of at most that length keeps itself and +/// reserves its bucket, as does every reserved key that falls inside the table. This has to +/// happen for **all** names before anything is hashed, or a hashed name could take a bucket that +/// a later preserved name needs. +/// 2. The remaining names are hashed into the table, resolving collisions by open addressing. +/// +/// Both passes iterate in sorted order, so the result depends only on the *set* of names, never on +/// the order they arrive in. +pub fn shorten_to_unique_names<'a>( + names: impl IntoIterator, +) -> FrozenMap { + let mut names: Vec<&RcStr> = names.into_iter().collect(); + names.sort_unstable(); + names.dedup(); + + if names.is_empty() { + return FrozenMap::default(); + } + + // A lone export always gets the same name, which compresses better across modules than a + // hashed one would — unless it is already short enough to keep, in which case it is omitted + // entirely (see the doc comment on the return value below). + if let [name] = names[..] { + return if bucket_of(name, 1).is_some() { + FrozenMap::default() + } else { + FrozenMap::from_iter([((*name).clone(), SINGLE_ITEM_IDENTIFIER)]) + }; + } + + // Grow the table until it can hold the names *and* the reserved buckets that fall inside it. + // Reserving can only ever push us up by the number of reserved keys, so this terminates. + let mut len = 1; + while capacity_for_len(len) < names.len() as u64 + reserved_in_table(len) { + len += 1; + } + let capacity = capacity_for_len(len); + + // Only entries for names that actually changed are kept — see the doc comment on the return + // value below — so this is sized for the common case (most names get hashed) rather than for + // every name. + let mut result = Vec::with_capacity(names.len()); + let mut used = + FxHashSet::with_capacity_and_hasher(names.len() + RESERVED_KEYS.len(), Default::default()); + + for name in RESERVED_KEYS { + if let Some(bucket) = bucket_of(name, len) { + used.insert(bucket); + } + } + + // Pass 1: names that are already valid short identifiers keep themselves and claim their + // bucket — nothing to record, since keeping a name is exactly what an absent entry means. + // This must complete before any hashing happens. + let mut to_mangle = Vec::with_capacity(names.len()); + for name in names { + match bucket_of(name, len) { + // A name that is also reserved can't keep itself; it gets a fresh bucket below. + Some(bucket) if used.insert(bucket) => {} + _ => to_mangle.push(name), + } + } + + // Pass 2: hash the rest into the table, probing linearly on collision. The assigned identifier + // can never equal the original name here: either the name never encoded to a valid identifier + // at all (so it can't equal one now), or it did but lost its own bucket to something else in + // pass 1, and a different bucket always encodes to a different string. + for name in to_mangle { + let mut bucket = hash_xxh3_hash64(name.as_str()) % capacity; + while !used.insert(bucket) { + bucket = (bucket + 1) % capacity; + } + result.push((name.clone(), RcStr::from(encode_js_identifier(bucket)))); + } + + FrozenMap::from_iter(result) +} + +#[cfg(test)] +mod tests { + use rustc_hash::FxHashMap; + + use super::*; + + /// Test convenience wrapper: [`shorten_to_unique_names`] itself only returns entries for names + /// that actually changed (see its doc comment), but most of the tests below read more + /// naturally against a *complete* map — one entry per input name, with an absent one filled in + /// as identity — so this reconstructs that view. + fn shorten(names: &[&str]) -> FxHashMap { + let names: Vec = names.iter().map(|name| RcStr::from(*name)).collect(); + let mangled = shorten_to_unique_names(names.iter()); + names + .iter() + .map(|name| { + let mangled = mangled.get(name).cloned().unwrap_or_else(|| name.clone()); + (name.clone(), mangled) + }) + .collect() + } + + fn assert_all_unique(map: &FxHashMap) { + let mut values: Vec<_> = map.values().cloned().collect(); + values.sort(); + let before = values.len(); + values.dedup(); + assert_eq!( + values.len(), + before, + "mangled names are not unique: {map:?}" + ); + } + + fn numbered(count: usize) -> Vec { + (0..count).map(|i| format!("exportNumber{i:02}")).collect() + } + + #[test] + fn encode_decode_roundtrip() { + for value in [ + 0, + 1, + 52, + 53, + 54, + 55, + 100, + 1000, + 10_000, + 100_000, + u32::MAX as u64, + u64::MAX, + ] { + let encoded = encode_js_identifier(value); + assert_eq!( + decode_js_identifier(&encoded), + Some(value), + "roundtrip failed for {value} ({encoded})" + ); + } + } + + #[test] + fn encoding_is_injective() { + // The property the assignment relies on: distinct buckets never render to the same name, so + // a name preserved in pass 1 can never be handed out again in pass 2. A trailing `_` is an + // ordinary digit here, so there is nothing to exclude. + let mut seen = FxHashMap::default(); + for value in 0..30_000u64 { + let encoded = encode_js_identifier(value); + if let Some(previous) = seen.insert(encoded.clone(), value) { + panic!("{value} and {previous} both encode to {encoded}"); + } + } + } + + #[test] + fn every_name_in_the_alphabet_decodes() { + // A trailing `_` is no longer degenerate: it is a digit like any other, and these are + // distinct values rather than aliases of a shorter name. + assert_eq!(decode_js_identifier("_"), Some(0)); + assert_ne!(decode_js_identifier("__"), decode_js_identifier("_")); + assert_ne!(decode_js_identifier("a_"), decode_js_identifier("a")); + for name in ["_", "__", "a_", "a", "if", "__esModule"] { + let value = decode_js_identifier(name).expect("in the alphabet"); + assert_eq!(encode_js_identifier(value), name, "roundtrip via {name}"); + } + + // Only characters outside the alphabets fail to decode. + assert_eq!(decode_js_identifier("a-b"), None); + assert_eq!(decode_js_identifier("é"), None); + // Digits may not lead an identifier. + assert_eq!(decode_js_identifier("0a"), None); + } + + #[test] + #[should_panic(expected = "identifiers are never empty")] + fn empty_name_panics() { + // An empty export name is a caller bug, not an un-decodable name. + decode_js_identifier(""); + } + + #[test] + fn table_length_follows_capacity() { + assert_eq!(capacity_for_len(1), 54); + assert_eq!(capacity_for_len(2), 54 + 54 * 64); + + // The worked example: 15 exports fit in one character. + let names = numbered(15); + let map = shorten(&names.iter().map(String::as_str).collect::>()); + assert_eq!(map.len(), 15); + for mangled in map.values() { + assert_eq!( + mangled.chars().count(), + 1, + "expected 1 character: {mangled}" + ); + } + + // 55 names no longer fit, so the table grows a character. + let names = numbered(55); + let map = shorten(&names.iter().map(String::as_str).collect::>()); + assert_eq!(map.len(), 55); + assert!(map.values().any(|m| m.chars().count() == 2)); + } + + #[test] + fn short_names_are_preserved() { + let map = shorten(&["a", "longExportName"]); + assert_eq!(map.get("a").map(RcStr::as_str), Some("a")); + assert_ne!(map.get("longExportName").map(RcStr::as_str), Some("a")); + assert_all_unique(&map); + } + + #[test] + fn preserved_names_claim_their_bucket_before_hashing() { + // The preservable names deliberately sort *last*, so an implementation that assigned hashed + // names as it walked the list would already have handed out their buckets. + let mut names = numbered(40); + names.push("z".to_string()); + names.push("A".to_string()); + names.push("$".to_string()); + + let map = shorten(&names.iter().map(String::as_str).collect::>()); + assert_eq!(map.get("z").map(RcStr::as_str), Some("z")); + assert_eq!(map.get("A").map(RcStr::as_str), Some("A")); + assert_eq!(map.get("$").map(RcStr::as_str), Some("$")); + for (name, mangled) in &map { + if !matches!(name.as_str(), "z" | "A" | "$") { + assert!( + !matches!(mangled.as_str(), "z" | "A" | "$"), + "{name} was assigned the preserved name {mangled}" + ); + } + } + assert_all_unique(&map); + } + + #[test] + fn a_single_export_always_gets_the_same_name() { + // Every single-export module in the graph emits the same key, so the `.f` / `.f()` byte + // sequences repeat across the bundle and compress together. The cost is that adding a + // second export renames this one, which is accepted deliberately. + let one = shorten(&["someVeryLongExportName"]); + assert_eq!( + one.get("someVeryLongExportName").map(RcStr::as_str), + Some("f") + ); + assert_eq!( + shorten(&["aCompletelyDifferentName"]) + .get("aCompletelyDifferentName") + .map(RcStr::as_str), + Some("f"), + "a lone export should not depend on its own name" + ); + } + + #[test] + fn a_single_short_export_still_keeps_itself() { + assert_eq!(shorten(&["a"]).get("a").map(RcStr::as_str), Some("a")); + } + + #[test] + fn empty() { + assert!(shorten(&[]).is_empty()); + } + + #[test] + fn unique_under_heavy_collision() { + let names: Vec = (0..100).map(|i| format!("export_{i}")).collect(); + let map = shorten(&names.iter().map(String::as_str).collect::>()); + assert_eq!(map.len(), 100); + assert_all_unique(&map); + } + + #[test] + fn assignment_is_order_independent() { + let forward = shorten(&["foobar", "barbaz", "bazqux", "a", "reallyLongName"]); + let backward = shorten(&["reallyLongName", "a", "bazqux", "barbaz", "foobar"]); + assert_eq!(forward, backward); + } + + #[test] + fn duplicate_names_are_collapsed() { + let map = shorten(&["foobar", "foobar", "barbaz"]); + assert_eq!(map.len(), 2); + assert_all_unique(&map); + } + + #[test] + fn collisions_take_the_next_bucket() { + // Fill the single-character table completely: with 54 names every bucket is taken, so every + // name still gets a distinct single character — only possible if collisions probe on. + let names: Vec = (0..54).map(|i| format!("collidingExport{i:02}")).collect(); + let map = shorten(&names.iter().map(String::as_str).collect::>()); + assert_eq!(map.len(), 54); + assert_all_unique(&map); + for mangled in map.values() { + assert_eq!( + mangled.chars().count(), + 1, + "expected 1 character: {mangled}" + ); + } + assert_eq!(map.len(), FIRST_CHARS.len()); + } + + #[test] + fn probing_wraps_around_the_table() { + // Every bucket but one is claimed by a preserved name, so the hashed name has to wrap + // around the end of the value space to find the only free bucket. + let free = FIRST_CHARS[7] as char; + let mut names: Vec = FIRST_CHARS + .iter() + .map(|&c| (c as char).to_string()) + .filter(|c| c != &free.to_string()) + .collect(); + names.push("aNameThatNeedsMangling".to_string()); + + let map = shorten(&names.iter().map(String::as_str).collect::>()); + assert_eq!( + map.get("aNameThatNeedsMangling").map(RcStr::as_str), + Some(free.to_string().as_str()), + "the only free bucket should have been found by wrapping" + ); + assert_all_unique(&map); + } + + #[test] + fn adding_a_name_within_the_same_tier_keeps_others_stable() { + let before = numbered(20); + let mut after = before.clone(); + after.push("oneMoreExport".to_string()); + + let map_before = shorten(&before.iter().map(String::as_str).collect::>()); + let map_after = shorten(&after.iter().map(String::as_str).collect::>()); + + // Same tier, so the modulus is unchanged: only names in the collision cluster of the new + // name may move. + let moved = map_before + .iter() + .filter(|(name, mangled)| map_after.get(*name) != Some(*mangled)) + .count(); + assert!( + moved <= 2, + "adding one name moved {moved} of {} existing names", + map_before.len() + ); + } + + #[test] + fn reserved_names_are_never_assigned() { + // Enough names that the two-character table is crowded, so a reserved bucket would be + // reached if it were not withheld. + let names: Vec = (0..3000) + .map(|i| format!("collidingExport{i:04}")) + .collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let map = shorten(&names); + assert_eq!(map.len(), 3000); + assert_all_unique(&map); + for mangled in map.values() { + assert!( + !RESERVED_KEYS.contains(&mangled.as_str()), + "handed out the reserved key {mangled}" + ); + } + } + + #[test] + fn a_name_that_is_itself_reserved_does_not_keep_itself() { + // `in` would normally keep its own name, but it is reserved, so it gets a fresh bucket. + let map = shorten(&["in", "someLongExportName"]); + assert_ne!(map.get("in").map(RcStr::as_str), Some("in")); + assert_all_unique(&map); + } + + #[test] + fn reserved_bucket_count_matches_the_reserved_list() { + // `reserved_in_table` is maintained by hand; keep it honest against the list it summarizes. + for len in 1..=12u32 { + let counted = RESERVED_KEYS + .iter() + .filter(|name| bucket_of(name, len).is_some()) + .count() as u64; + assert_eq!( + reserved_in_table(len), + counted, + "reserved_in_table({len}) disagrees with RESERVED_KEYS" + ); + } + } + + #[test] + fn capacity_for_len_matches_the_formula() { + // `CAPACITY_FOR_LEN` is precomputed to keep it off `decode_js_identifier`'s hot path; keep + // it honest against the closed form it stands in for, `54 * (64^len - 1) / 63`. Computed in + // `u128` so this check doesn't itself rely on the saturation it is verifying. + for len in 0..CAPACITY_FOR_LEN.len() as u32 { + let first = FIRST_CHARS.len() as u128; + let rest = REST_CHARS.len() as u128; + let expected = first * (rest.pow(len) - 1) / (rest - 1); + assert_eq!( + capacity_for_len(len) as u128, + expected, + "capacity_for_len({len}) disagrees with the formula" + ); + } + // And confirm it still saturates beyond the precomputed range, rather than overflowing. + assert_eq!(capacity_for_len(CAPACITY_FOR_LEN.len() as u32), u64::MAX); + assert_eq!(capacity_for_len(1000), u64::MAX); + } + + #[test] + fn keyword_keys_are_never_handed_out() { + // `ns["if"]` is legal but a minifier will not fold it to `ns.if`, so it costs bytes. The + // words are withheld from the table like any other reserved key. + for keyword in RESERVED_KEYS.iter().filter(|k| k.len() <= 3) { + let value = decode_js_identifier(keyword) + .unwrap_or_else(|| panic!("{keyword} should be in the encoding's image")); + assert_eq!( + encode_js_identifier(value), + *keyword, + "{keyword} must round-trip, or withholding its bucket does nothing" + ); + } + + // Enough names to need two characters, which is where the words become reachable. + let names: Vec = (0..2000).map(|i| format!("exportNumber{i:04}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let map = shorten(&names); + assert_all_unique(&map); + assert_eq!(map.len(), 2000); + for mangled in map.values() { + assert!( + !RESERVED_KEYS.contains(&mangled.as_str()), + "handed out the reserved key {mangled}" + ); + } + } + + #[test] + fn an_export_named_like_a_keyword_is_renamed_away_from_it() { + // Keeping `if` would emit `ns["if"]` (8 bytes); renaming it to a dot-accessible key gets + // `ns.ab` (5), so withholding the word is a win even for a source name that is already + // short enough to keep. + let map = shorten(&["if", "someLongExportName"]); + let mangled = map.get("if").unwrap(); + assert_ne!(mangled.as_str(), "if"); + assert_eq!(mangled.chars().count(), 1, "should still be a short key"); + assert_all_unique(&map); + } +} diff --git a/turbopack/crates/turbopack-ecmascript/src/references/esm/mod.rs b/turbopack/crates/turbopack-ecmascript/src/references/esm/mod.rs index d5eef8f1f6a4..96fe9eb88a7d 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/esm/mod.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/esm/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod base; pub(crate) mod binding; pub(crate) mod dynamic; pub(crate) mod export; +pub(crate) mod mangle; pub(crate) mod meta; pub(crate) mod module_id; pub(crate) mod module_item; @@ -12,6 +13,7 @@ pub use self::{ binding::EsmBinding, dynamic::EsmAsyncAssetReference, export::{EsmExport, EsmExports, FoundExportType, Liveness}, + mangle::generated_export_key, meta::{ImportMetaBinding, ImportMetaRef}, module_item::EsmModuleItem, url::{UrlAssetReference, UrlRewriteBehavior}, diff --git a/turbopack/crates/turbopack-ecmascript/src/references/exports.rs b/turbopack/crates/turbopack-ecmascript/src/references/exports.rs index 80ced8a69aa5..3f114f8fe65a 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/exports.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/exports.rs @@ -179,6 +179,7 @@ pub async fn compute_ecmascript_module_exports( let esm_exports = EsmExports { exports: esm_exports, star_exports: esm_star_exports, + mangle_export_names: options.mangle_export_names, } .cell(); @@ -199,6 +200,7 @@ pub async fn compute_ecmascript_module_exports( EsmExports { exports: Default::default(), star_exports: Default::default(), + mangle_export_names: options.mangle_export_names, } .resolved_cell(), ) @@ -210,6 +212,7 @@ pub async fn compute_ecmascript_module_exports( EsmExports { exports: Default::default(), star_exports: Default::default(), + mangle_export_names: options.mangle_export_names, } .resolved_cell(), ), @@ -224,6 +227,7 @@ pub async fn compute_ecmascript_module_exports( EsmExports { exports: Default::default(), star_exports: Default::default(), + mangle_export_names: options.mangle_export_names, } .resolved_cell(), ) diff --git a/turbopack/crates/turbopack-ecmascript/src/references/exports_info.rs b/turbopack/crates/turbopack-ecmascript/src/references/exports_info.rs index c1439d43dcf6..9d31f0f4d873 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/exports_info.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/exports_info.rs @@ -13,7 +13,7 @@ use crate::{ chunk::{EcmascriptChunkPlaceable, EcmascriptExports}, code_gen::{CodeGen, CodeGeneration}, create_visitor, magic_identifier, - references::AstPath, + references::{AstPath, esm::mangle::mangled_export_names}, }; /// Responsible for initializing the `ExportsInfoBinding` object binding, so that it may be @@ -44,18 +44,48 @@ impl ExportsInfoBinding { .module_export_usage(*ResolvedVc::upcast(module)) .await?; let export_usage_info = export_usage_info.export_usage.await?; + // The keys of `__webpack_exports_info__` stay the *original* export names — user code + // looks them up by name. The emitted key is reported as `mangledName` instead, which is + // always present alongside `canMangle` (`null` when `canMangle` is false), regardless of + // whether export mangling is enabled at all — see the `map` closure below for exactly + // what each of the three fields means. + let exports = exports.await?; + let mangled_names = mangled_export_names(*module, chunking_context).await?; - let props = if let EcmascriptExports::EsmExports(exports) = &*exports.await? { + let props = if let EcmascriptExports::EsmExports(exports) = &*exports { exports .await? .exports .keys() .map(|e| { - let used: Expr = export_usage_info.is_export_used(e).into(); + let is_used = export_usage_info.is_export_used(e); + let used: Expr = is_used.into(); + // `canMangle` is true exactly when this export is a genuine candidate for + // mangling: the module has to be eligible at all (which is what a `Some` map + // means — see `mangled_export_names`) and the export itself has to be used, as + // an unused export is never emitted and so was never a candidate. + // `mangledName` is then always a string — the assigned key when mangling + // actually renamed it, or the export's own name when it was considered but + // kept itself (e.g. already short enough) — and only `null` when `canMangle` + // is false. + let can_mangle_names = mangled_names.as_ref().filter(|_| is_used); + let can_mangle_expr: Expr = can_mangle_names.is_some().into(); + let mangled_name: Expr = + match can_mangle_names { + Some(names) => Expr::Lit(names.get(e).unwrap_or(e).as_str().into()), + None => Expr::Lit(swc_core::ecma::ast::Lit::Null( + swc_core::ecma::ast::Null { span: DUMMY_SP }, + )), + }; PropOrSpread::Prop(Box::new(swc_core::ecma::ast::Prop::KeyValue( KeyValueProp { key: PropName::Str(e.as_str().into()), - value: quote!("{ used: $v }" as Box, v: Expr = used), + value: quote!( + "{ used: $v, canMangle: $c, mangledName: $m }" as Box, + v: Expr = used, + c: Expr = can_mangle_expr, + m: Expr = mangled_name + ), }, ))) }) diff --git a/turbopack/crates/turbopack-ecmascript/src/references/import_meta_glob.rs b/turbopack/crates/turbopack-ecmascript/src/references/import_meta_glob.rs index 6e88c23849c7..5b8f992aa665 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/import_meta_glob.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/import_meta_glob.rs @@ -460,14 +460,18 @@ impl ImportMetaGlobMap { true } }) - .map(|(_base_relative, path)| { + .map(|(base_relative, _logical_path)| { let origin_path = &origin_path; + let base_dir = &base_dir; let query = &query; let reference_sub_type = &reference_sub_type; async move { - // Compute the origin-relative path for import resolution and as the - // user-visible key in the result object. - let Some(origin_relative) = origin_path.get_relative_path_to(path) else { + // ReadGlobResult paths are logical too, but reconstruct from its keys here so + // matching and user-visible specifiers have one explicit source of truth. The + // module resolver resolves this logical request and tracks its symlink chain. + let logical_path = base_dir.join(base_relative)?; + let Some(origin_relative) = origin_path.get_relative_path_to(&logical_path) + else { bail!( "import.meta.glob: failed to compute relative path from origin to \ matched file" diff --git a/turbopack/crates/turbopack-ecmascript/src/rename/module.rs b/turbopack/crates/turbopack-ecmascript/src/rename/module.rs index ce5e5b7f0886..f44ad599177d 100644 --- a/turbopack/crates/turbopack-ecmascript/src/rename/module.rs +++ b/turbopack/crates/turbopack-ecmascript/src/rename/module.rs @@ -241,6 +241,12 @@ impl EcmascriptChunkPlaceable for EcmascriptModuleRenameModule { let exports = EsmExports { exports: FrozenMap::from_unique_sorted_box(Box::new([export])), star_exports: Vec::new(), + // This module only re-exports one binding of `self.module` under a different name, so + // whether its own key may be shortened follows the module it renames. + mangle_export_names: match &*self.module.get_exports().await? { + EcmascriptExports::EsmExports(exports) => exports.await?.mangle_export_names, + _ => false, + }, } .resolved_cell(); Ok(EcmascriptExports::EsmExports(exports).cell()) diff --git a/turbopack/crates/turbopack-ecmascript/src/side_effect_optimization/facade/module.rs b/turbopack/crates/turbopack-ecmascript/src/side_effect_optimization/facade/module.rs index b320f17ababa..2ac305180582 100644 --- a/turbopack/crates/turbopack-ecmascript/src/side_effect_optimization/facade/module.rs +++ b/turbopack/crates/turbopack-ecmascript/src/side_effect_optimization/facade/module.rs @@ -243,6 +243,7 @@ impl EcmascriptChunkPlaceable for EcmascriptModuleFacadeModule { let exports = EsmExports { exports: FrozenMap::from_unique_sorted_box(exports.into_boxed_slice()), star_exports: esm_exports.star_exports.clone(), + mangle_export_names: esm_exports.mangle_export_names, } .resolved_cell(); Ok(EcmascriptExports::EsmExports(exports).cell()) diff --git a/turbopack/crates/turbopack-ecmascript/src/side_effect_optimization/locals/module.rs b/turbopack/crates/turbopack-ecmascript/src/side_effect_optimization/locals/module.rs index 12e6f412e58c..3d86cd2434cb 100644 --- a/turbopack/crates/turbopack-ecmascript/src/side_effect_optimization/locals/module.rs +++ b/turbopack/crates/turbopack-ecmascript/src/side_effect_optimization/locals/module.rs @@ -184,6 +184,7 @@ impl EcmascriptChunkPlaceable for EcmascriptModuleLocalsModule { let exports = EsmExports { exports: FrozenMap::from_unique_sorted_box(exports.into_boxed_slice()), star_exports: vec![], + mangle_export_names: esm_exports.mangle_export_names, } .resolved_cell(); Ok(EcmascriptExports::EsmExports(exports).cell()) diff --git a/turbopack/crates/turbopack-ecmascript/src/worker_chunk/module.rs b/turbopack/crates/turbopack-ecmascript/src/worker_chunk/module.rs index 3e5d6b0af405..c933b61a52a2 100644 --- a/turbopack/crates/turbopack-ecmascript/src/worker_chunk/module.rs +++ b/turbopack/crates/turbopack-ecmascript/src/worker_chunk/module.rs @@ -27,6 +27,7 @@ use crate::{ EcmascriptExports, data::EcmascriptChunkData, ecmascript_chunk_item, }, embed_js::embed_fs, + references::esm::generated_export_key, runtime_functions::{TURBOPACK_EXPORT_VALUE, TURBOPACK_REQUIRE}, utils::{StringifyJs, StringifyModuleId}, }; @@ -261,6 +262,10 @@ impl EcmascriptChunkPlaceable for WorkerLoaderModule { // otherwise we will induce a turbo tasks cycle. But we only need an // approximate solution. We'll use the same estimate for both web // and Node.js workers. + // + // That includes the export key: resolving the real one needs the chunking context, so + // the estimate uses the source name even when the helper's exports are mangled. It can + // only be off by a few characters. let fake_id = ModuleId::String(rcstr!("a_fake_module")); return Ok(EcmascriptChunkItemContent { inner_code: formatdoc! { @@ -277,10 +282,19 @@ impl EcmascriptChunkPlaceable for WorkerLoaderModule { .cell()); } - let create_worker_id = self - .create_worker_module() - .chunk_item_id(chunking_context) - .await?; + let create_worker_module = self.create_worker_module(); + let create_worker_id = create_worker_module.chunk_item_id(chunking_context).await?; + // The helper's `default` export is read here as a string, so it has to go through the same + // mapping the helper itself emits — a hard-coded `["default"]` misses once its exports are + // mangled. + let create_worker_export = match ResolvedVc::try_sidecast::>( + create_worker_module.to_resolved().await?, + ) { + Some(placeable) => { + generated_export_key(placeable, chunking_context, &rcstr!("default")).await? + } + None => rcstr!("default"), + }; let code = match this.worker_type { WorkerType::WebWorker | WorkerType::SharedWebWorker => { @@ -306,11 +320,12 @@ impl EcmascriptChunkPlaceable for WorkerLoaderModule { formatdoc! { r#" - {TURBOPACK_EXPORT_VALUE}({TURBOPACK_REQUIRE}({workers_module})["default"]({entrypoint}, {chunks})); + {TURBOPACK_EXPORT_VALUE}({TURBOPACK_REQUIRE}({workers_module})[{export:#}]({entrypoint}, {chunks})); "#, entrypoint = StringifyJs(&entrypoint_path), chunks = StringifyJs(&chunks_data), workers_module = StringifyModuleId(&create_worker_id), + export = StringifyJs(&create_worker_export), } } WorkerType::NodeWorkerThread => { @@ -338,10 +353,11 @@ impl EcmascriptChunkPlaceable for WorkerLoaderModule { // directory formatdoc! { r#" - {TURBOPACK_EXPORT_VALUE}({TURBOPACK_REQUIRE}({workers_module})["default"](__dirname + "/" + {worker_path:#})); + {TURBOPACK_EXPORT_VALUE}({TURBOPACK_REQUIRE}({workers_module})[{export:#}](__dirname + "/" + {worker_path:#})); "#, worker_path = StringifyJs(entry_path.file_name()), workers_module = StringifyModuleId(&create_worker_id), + export = StringifyJs(&create_worker_export), } } }; diff --git a/turbopack/crates/turbopack-tests/tests/execution.rs b/turbopack/crates/turbopack-tests/tests/execution.rs index 8fbfda911939..981228c5f06d 100644 --- a/turbopack/crates/turbopack-tests/tests/execution.rs +++ b/turbopack/crates/turbopack-tests/tests/execution.rs @@ -281,6 +281,8 @@ struct TestOptions { #[serde(default)] cjs_tree_shaking: bool, #[serde(default = "default_true")] + mangle_export_names: bool, + #[serde(default = "default_true")] cross_module_constants: bool, #[serde(default)] cjs_scope_hoisting: bool, @@ -312,6 +314,7 @@ impl Default for TestOptions { remove_unused_imports: default_true(), scope_hoisting: default_true(), cjs_tree_shaking: false, + mangle_export_names: default_true(), cjs_scope_hoisting: false, cross_module_constants: true, infer_module_side_effects: default_true(), @@ -479,6 +482,7 @@ async fn run_test_operation(prepared_test: ResolvedVc) -> Result ns`), the analysis cannot enumerate the reads, so the +// module's usage widens to `ModuleExportUsageInfo::All` and mangling backs off on that. Forcing the +// facade / locals split for mangled modules does not help: the widened usage propagates through the +// facade to the locals module, so both keep their original keys. Materialization needs the facade to +// pin the original names *while* declaring a known, named usage of the locals module. +// +// Lives under `__skipped__`, so the harness asserts it still fails. When materialization is +// implemented this fixture starts passing, the suite goes red, and it should be moved out of +// `__skipped__`. + +import { getEnums } from './provider' + +it('should still mangle a module whose namespace escapes', () => { + const ns = getEnums() + // The namespace object keeps the original names... + expect(ns.ENUM_A).toBe('a-value') + expect(ns.ENUM_B).toBe('b-value') + expect(ns.default).toBe('default-value') + expect(Object.keys(ns).sort()).toEqual([ + 'ENUM_A', + 'ENUM_B', + 'default', + 'exportsInfo', + ]) + + // ...while the module's own export keys are still shortened. This is the part that isn't + // implemented: today `canMangle` is false here because we back off instead. + expect(ns.exportsInfo.ENUM_A.canMangle).toBe(true) + expect(ns.exportsInfo.ENUM_A.mangledName).not.toBe('ENUM_A') + expect(ns.exportsInfo.ENUM_B.mangledName).not.toBe( + ns.exportsInfo.ENUM_A.mangledName + ) +}) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/__skipped__/mangle-materialized-namespace/input/provider.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/__skipped__/mangle-materialized-namespace/input/provider.js new file mode 100644 index 000000000000..0a87bfdc3b04 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/__skipped__/mangle-materialized-namespace/input/provider.js @@ -0,0 +1,4 @@ +import * as ns from './enums' + +// The namespace object escapes, so it cannot be statically tracked. +export const getEnums = () => ns diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/__skipped__/mangle-materialized-namespace/options.json b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/__skipped__/mangle-materialized-namespace/options.json new file mode 100644 index 000000000000..c06a1097d5b0 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/__skipped__/mangle-materialized-namespace/options.json @@ -0,0 +1,5 @@ +{ + "mangleExportNames": true, + "minify": true, + "scopeHoisting": false +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-basic/input/index.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-basic/input/index.js new file mode 100644 index 000000000000..701fd8e23117 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-basic/input/index.js @@ -0,0 +1,45 @@ +import { + a, + b, + foo, + reallyLongExportName, + anotherVeryLongExportName, + shortFn, + thisIsAVeryLongFunctionName, + exportsInfo, +} from './named-exports' + +it('should keep the values of mangled exports correct', () => { + expect(a).toBe('short-a') + expect(b).toBe('short-b') + expect(foo).toBe('short-foo') + expect(reallyLongExportName).toBe('long-name-1') + expect(anotherVeryLongExportName).toBe('long-name-2') + expect(shortFn()).toBe('short-fn') + expect(thisIsAVeryLongFunctionName()).toBe('long-fn') +}) + +it('should actually mangle the exported names', () => { + // `__webpack_exports_info__` is keyed by the original names and reports the key the export is + // emitted under, which is how a running test can observe that mangling happened at all. + expect(exportsInfo.reallyLongExportName.canMangle).toBe(true) + expect(exportsInfo.reallyLongExportName.mangledName).toEqual( + expect.any(String) + ) + expect( + exportsInfo.reallyLongExportName.mangledName.length + ).toBeLessThanOrEqual(2) + expect(exportsInfo.thisIsAVeryLongFunctionName.mangledName).not.toBe( + 'thisIsAVeryLongFunctionName' + ) + // Distinct exports never share a key. + expect(exportsInfo.reallyLongExportName.mangledName).not.toBe( + exportsInfo.thisIsAVeryLongFunctionName.mangledName + ) +}) + +it('should keep a name that is already short', () => { + // `a` is already a valid one-character identifier, so it reserves that bucket and keeps itself + // instead of being renamed. + expect(exportsInfo.a.mangledName).toBe('a') +}) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-basic/input/named-exports.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-basic/input/named-exports.js new file mode 100644 index 000000000000..2d9a96e3cce4 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-basic/input/named-exports.js @@ -0,0 +1,23 @@ +// Every export here is imported by name, so the keys this module emits are only ever read by +// generated code and can be shortened. + +export const a = 'short-a' +export const b = 'short-b' +export const foo = 'short-foo' +export const reallyLongExportName = 'long-name-1' +export const anotherVeryLongExportName = 'long-name-2' + +export function shortFn() { + return 'short-fn' +} + +export function thisIsAVeryLongFunctionName() { + return 'long-fn' +} + +export const exportsInfo = { + a: __webpack_exports_info__.a, + reallyLongExportName: __webpack_exports_info__.reallyLongExportName, + thisIsAVeryLongFunctionName: + __webpack_exports_info__.thisIsAVeryLongFunctionName, +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-basic/options.json b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-basic/options.json new file mode 100644 index 000000000000..c06a1097d5b0 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-basic/options.json @@ -0,0 +1,5 @@ +{ + "mangleExportNames": true, + "minify": true, + "scopeHoisting": false +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-cjs-consumer/input/cjs-consumer.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-cjs-consumer/input/cjs-consumer.js new file mode 100644 index 000000000000..691ab04bece7 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-cjs-consumer/input/cjs-consumer.js @@ -0,0 +1,6 @@ +const esm = require('./esm') + +exports.readNamed = () => esm.someLongExportName +exports.readOther = () => esm.anotherLongExportName +exports.exportsInfo = esm.exportsInfo +exports.keys = () => Object.keys(esm) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-cjs-consumer/input/esm.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-cjs-consumer/input/esm.js new file mode 100644 index 000000000000..613c956d89d0 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-cjs-consumer/input/esm.js @@ -0,0 +1,9 @@ +// Imported by a CommonJS module through `require()`. The property accesses on the required object +// live in user source and are not rewritten, so this module must keep its original names. + +export const someLongExportName = 'esm-1' +export const anotherLongExportName = 'esm-2' + +export const exportsInfo = { + someLongExportName: __webpack_exports_info__.someLongExportName, +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-cjs-consumer/input/index.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-cjs-consumer/input/index.js new file mode 100644 index 000000000000..e9f20c2b1d31 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-cjs-consumer/input/index.js @@ -0,0 +1,16 @@ +import { readNamed, readOther, exportsInfo, keys } from './cjs-consumer' +import { someLongExportName } from './esm' + +it('should keep a CommonJS consumer of an ESM module working', () => { + expect(readNamed()).toBe('esm-1') + expect(readOther()).toBe('esm-2') + // The ESM import of the same module resolves to the same binding. + expect(someLongExportName).toBe('esm-1') +}) + +it('should not mangle a module that a CommonJS module requires', () => { + // The `esm.someLongExportName` accesses in `cjs-consumer.js` are user source, so the names have + // to stay as written. + expect(exportsInfo.someLongExportName.canMangle).toBe(false) + expect(keys()).toContain('someLongExportName') +}) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-cjs-consumer/options.json b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-cjs-consumer/options.json new file mode 100644 index 000000000000..65869c16c6b5 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-cjs-consumer/options.json @@ -0,0 +1,6 @@ +{ + "mangleExportNames": true, + "minify": true, + "scopeHoisting": false, + "cjsTreeShaking": true +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/input/es-module-name.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/input/es-module-name.js new file mode 100644 index 000000000000..74da3f294c7d --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/input/es-module-name.js @@ -0,0 +1,10 @@ +// `__esModule` is how a CommonJS module signals interop *to* ESM; the runtime defines it on the +// exports object itself. An ESM module that happens to export that name is just an ordinary export, +// so its key is mangled like any other — and the runtime's own `__esModule` is untouched. + +export const __esModule = 'a-normal-export' +export const someLongExportName = 'named-value' + +export const exportsInfo = { + __esModule: __webpack_exports_info__.__esModule, +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/input/index.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/input/index.js new file mode 100644 index 000000000000..95dd307dfed8 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/input/index.js @@ -0,0 +1,34 @@ +import theDefault, { someLongExportName, exportsInfo } from './lib' +import throughReexport, { renamedDefault } from './reexport' +import { + __esModule as esModuleExport, + exportsInfo as esModuleExportsInfo, +} from './es-module-name' + +it('should keep the default export working when it is mangled', () => { + expect(theDefault()).toBe('default-value') + expect(someLongExportName).toBe('named-value') +}) + +it('should mangle the default export key', () => { + expect(exportsInfo.default.canMangle).toBe(true) + expect(exportsInfo.default.mangledName).not.toBe('default') + expect(exportsInfo.default.mangledName.length).toBeLessThanOrEqual(2) + // `default` shares the table with the other exports, so it never collides with them. + expect(exportsInfo.default.mangledName).not.toBe( + exportsInfo.someLongExportName.mangledName + ) +}) + +it('should carry a mangled default through a re-export', () => { + expect(throughReexport()).toBe('default-value') + expect(renamedDefault()).toBe('default-value') +}) + +it('should mangle an export literally named __esModule', () => { + // The name is only an interop marker when a CommonJS module sets it; as an ESM export name it + // carries no meaning, so its key is shortened like any other. + expect(esModuleExport).toBe('a-normal-export') + expect(esModuleExportsInfo.__esModule.canMangle).toBe(true) + expect(esModuleExportsInfo.__esModule.mangledName).not.toBe('__esModule') +}) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/input/lib.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/input/lib.js new file mode 100644 index 000000000000..2754db7418fc --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/input/lib.js @@ -0,0 +1,14 @@ +// `default` is mangled like any other export key: every read of it in an eligible module is +// generated by us. Modules whose names can be observed (CommonJS interop, an escaping namespace, +// a dynamic import) keep `default` along with everything else — see the other `mangle-*` fixtures. + +export default function theDefaultExport() { + return 'default-value' +} + +export const someLongExportName = 'named-value' + +export const exportsInfo = { + default: __webpack_exports_info__.default, + someLongExportName: __webpack_exports_info__.someLongExportName, +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/input/reexport.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/input/reexport.js new file mode 100644 index 000000000000..41cc13bc6c6d --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/input/reexport.js @@ -0,0 +1,3 @@ +// A default export travelling through a re-export, in both spellings. +export { default } from './lib' +export { default as renamedDefault } from './lib' diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/options.json b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/options.json new file mode 100644 index 000000000000..c06a1097d5b0 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-default/options.json @@ -0,0 +1,5 @@ +{ + "mangleExportNames": true, + "minify": true, + "scopeHoisting": false +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-destructuring/input/index.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-destructuring/input/index.js new file mode 100644 index 000000000000..d1112f3aa09d --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-destructuring/input/index.js @@ -0,0 +1,32 @@ +import * as mod from './module' + +it('should keep values correct when destructuring the namespace', () => { + const { aVeryLongExportName, anotherVeryLongExportName } = mod + expect(aVeryLongExportName).toBe('a-value') + expect(anotherVeryLongExportName).toBe('b-value') +}) + +it('should keep values correct when destructuring a namespace property', () => { + const { a, b } = mod.objectValuedExportName + expect(a).toBe('a') + expect(b).toBe('b') +}) + +it('should keep values correct with member access on the namespace', () => { + expect(mod.aVeryLongExportName).toBe('a-value') +}) + +it('should handle `default` when destructuring a namespace', () => { + const { default: value } = mod + expect(value).toBe('default-value') +}) + +it('should back off for a module read through a namespace binding', () => { + // Reads through an `import * as ns` binding are reported as a *partial namespace object*: we + // know which names are used, but not whether every read was lowered to a direct named access, + // and a read that wasn't (a destructuring pattern, say) still uses the original name. So the + // module keeps its names. Distinguishing lowered reads from a materialized namespace object is + // a follow-up; it would unlock mangling for namespace-imported modules too. + expect(mod.exportsInfo.aVeryLongExportName.canMangle).toBe(false) + expect(mod.exportsInfo.aVeryLongExportName.mangledName).toBe(null) +}) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-destructuring/input/module.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-destructuring/input/module.js new file mode 100644 index 000000000000..32e024ecd525 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-destructuring/input/module.js @@ -0,0 +1,9 @@ +export const aVeryLongExportName = 'a-value' +export const anotherVeryLongExportName = 'b-value' +export const objectValuedExportName = { a: 'a', b: 'b' } +export default 'default-value' + +export const exportsInfo = { + aVeryLongExportName: __webpack_exports_info__.aVeryLongExportName, + objectValuedExportName: __webpack_exports_info__.objectValuedExportName, +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-destructuring/options.json b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-destructuring/options.json new file mode 100644 index 000000000000..c06a1097d5b0 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-destructuring/options.json @@ -0,0 +1,5 @@ +{ + "mangleExportNames": true, + "minify": true, + "scopeHoisting": false +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/input/dynamic-cjs.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/input/dynamic-cjs.js new file mode 100644 index 000000000000..c4c2e9774d20 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/input/dynamic-cjs.js @@ -0,0 +1,5 @@ +// A CommonJS module whose exports can't be determined statically, so a re-export of it has to be +// resolved at runtime by property access on the original names. +const key = 'dynamicallyNamedExport' +exports[key] = 'dynamic-value' +exports.staticallyNamedExport = 'static-value' diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/input/index.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/input/index.js new file mode 100644 index 000000000000..89928dc5c918 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/input/index.js @@ -0,0 +1,16 @@ +import { ownLongExportName, staticallyNamedExport } from './reexport' +import * as ns from './reexport' + +it('should keep a re-export of dynamic exports working', () => { + expect(ownLongExportName).toBe('own-value') + expect(staticallyNamedExport).toBe('static-value') +}) + +it('should expose the original names of a module with dynamic re-exports', () => { + // The star re-export is resolved at runtime by property access on the original names, so the + // names this module exposes must be the ones written in the source. + expect(ns.ownLongExportName).toBe('own-value') + expect(ns.staticallyNamedExport).toBe('static-value') + expect(ns.dynamicallyNamedExport).toBe('dynamic-value') + expect(Object.keys(ns)).toContain('ownLongExportName') +}) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/input/reexport.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/input/reexport.js new file mode 100644 index 000000000000..39647c57427d --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/input/reexport.js @@ -0,0 +1,5 @@ +// `export *` from a module with dynamic exports: the re-exported names are only known at runtime, +// so this module keeps its original export names. +export * from './dynamic-cjs' + +export const ownLongExportName = 'own-value' diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/issues/unexpected export __star__-aa3e7d.txt b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/issues/unexpected export __star__-aa3e7d.txt new file mode 100644 index 000000000000..cf7908c77ece --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/issues/unexpected export __star__-aa3e7d.txt @@ -0,0 +1,9 @@ +warning - [analysis] /turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/input/dynamic-cjs.js unexpected export * + + export * used with module [project]/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/input/dynamic-cjs.js [test] (ecmascript) which is a CommonJS module with exports only available at runtime + List all export names manually (`export { a, b, c } from "...") or rewrite the module to ESM, to avoid the additional runtime code.` + + Import trace: + test: + ./turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/input/dynamic-cjs.js + ./turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/input/index.js \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/options.json b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/options.json new file mode 100644 index 000000000000..c06a1097d5b0 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-dynamic-exports/options.json @@ -0,0 +1,5 @@ +{ + "mangleExportNames": true, + "minify": true, + "scopeHoisting": false +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/cjs-provider.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/cjs-provider.js new file mode 100644 index 000000000000..3caec227a216 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/cjs-provider.js @@ -0,0 +1,5 @@ +import * as ns from './cjs' + +// A CommonJS namespace escaping through the interop layer. CommonJS exports are never mangled, +// and the interop object must keep working unchanged. +export const getCjs = () => ns diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/cjs.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/cjs.js new file mode 100644 index 000000000000..41dcaf493c42 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/cjs.js @@ -0,0 +1,2 @@ +exports.CJS_A = 'cjs-a' +exports.CJS_B = 'cjs-b' diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/destr.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/destr.js new file mode 100644 index 000000000000..799e24d95eb7 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/destr.js @@ -0,0 +1,8 @@ +import * as ns from './enums' + +// A statically tracked read of the same module whose namespace escapes elsewhere. It has to keep +// working, and it has to read the same (original) name the escaping namespace exposes. +export function read() { + const { ENUM_B } = ns + return ENUM_B +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/enums.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/enums.js new file mode 100644 index 000000000000..2498439743c4 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/enums.js @@ -0,0 +1,8 @@ +// A namespace object of this module escapes into user code (see `provider.js`), so its export +// names are observable and must not be shortened. + +export const ENUM_A = 'a-value' +export const ENUM_B = 'b-value' +export const ENUM_C = 'c-value' +export const NUM = 42 +export default 'default-value' diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/index.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/index.js new file mode 100644 index 000000000000..63ca1d873642 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/index.js @@ -0,0 +1,57 @@ +import { getEnums } from './provider' +import { enumsNs } from './reexport' +import { getCjs } from './cjs-provider' +import { read } from './destr' +import { + someLongExportName, + anotherLongExportName, + exportsInfo, +} from './mangleable' + +it('should keep an escaped namespace object readable by its original names', () => { + const ns = getEnums() + expect(ns.ENUM_A).toBe('a-value') + expect(ns.ENUM_B).toBe('b-value') + expect(ns.ENUM_C).toBe('c-value') + expect(ns.NUM).toBe(42) + expect(ns.default).toBe('default-value') +}) + +it('should expose the original names when enumerating an escaped namespace', () => { + const keys = Object.keys(getEnums()).sort() + expect(keys).toEqual(['ENUM_A', 'ENUM_B', 'ENUM_C', 'NUM', 'default']) +}) + +it('should keep an escaped re-exported (export * as) namespace working', () => { + const get = () => enumsNs + const ns = get() + expect(ns.ENUM_A).toBe('a-value') + expect(ns.default).toBe('default-value') +}) + +it('should keep destructuring working for a module that also escapes', () => { + expect(read()).toBe('b-value') +}) + +it('should keep `delete ns.member` valid when the namespace escapes', () => { + // Deleting a member that does not exist returns true per spec, and must not be emitted as a + // bare `delete undefined`, which is a SyntaxError in strict mode. + const ns = getEnums() + expect(delete ns.doesNotExist).toBe(true) + expect(ns.ENUM_A).toBe('a-value') +}) + +it('should keep an escaped CommonJS namespace interop correct', () => { + const ns = getCjs() + expect(ns.CJS_A).toBe('cjs-a') + expect(ns.CJS_B).toBe('cjs-b') +}) + +it('should still mangle a sibling module that does not escape', () => { + expect(someLongExportName).toBe('mangled-1') + expect(anotherLongExportName).toBe('mangled-2') + expect(exportsInfo.someLongExportName.canMangle).toBe(true) + expect(exportsInfo.someLongExportName.mangledName).not.toBe( + 'someLongExportName' + ) +}) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/mangleable.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/mangleable.js new file mode 100644 index 000000000000..8a3ebddcf6e0 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/mangleable.js @@ -0,0 +1,10 @@ +// Nothing escapes here: every export is read by name, so this module *is* mangled even though +// another module in the same graph had to back off. Back-off is per module. + +export const someLongExportName = 'mangled-1' +export const anotherLongExportName = 'mangled-2' + +export const exportsInfo = { + someLongExportName: __webpack_exports_info__.someLongExportName, + anotherLongExportName: __webpack_exports_info__.anotherLongExportName, +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/provider.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/provider.js new file mode 100644 index 000000000000..588b6a383222 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/provider.js @@ -0,0 +1,5 @@ +import * as ns from './enums' + +// The whole namespace object escapes through a function return, so no static analysis can tell +// which names the caller will read. +export const getEnums = () => ns diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/reexport.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/reexport.js new file mode 100644 index 000000000000..49d4209fb319 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/input/reexport.js @@ -0,0 +1,2 @@ +// A re-exported namespace object that later escapes. +export * as enumsNs from './enums' diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/options.json b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/options.json new file mode 100644 index 000000000000..c06a1097d5b0 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-escaping-namespace/options.json @@ -0,0 +1,5 @@ +{ + "mangleExportNames": true, + "minify": true, + "scopeHoisting": false +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-hoisted/input/index.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-hoisted/input/index.js new file mode 100644 index 000000000000..2e7c07d7e78a --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-hoisted/input/index.js @@ -0,0 +1,20 @@ +import { + someLongExportName, + anotherLongExportName, + aLongFunctionName, +} from './shared' + +it('should be correct with scope hoisting enabled', () => { + expect(someLongExportName).toBe('shared-1') + expect(anotherLongExportName).toBe('shared-2') + expect(aLongFunctionName()).toBe('shared-fn') +}) + +it('should be correct across a chunk boundary with scope hoisting enabled', async () => { + // `lazy.js` lives in another chunk and reads the same module, so the binding has to be resolved + // through the emitted export object rather than a merged local. + const { fromLazy } = await import( + /* turbopackExports: ["fromLazy"] */ './lazy' + ) + expect(fromLazy()).toBe('shared-1/shared-fn') +}) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-hoisted/input/lazy.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-hoisted/input/lazy.js new file mode 100644 index 000000000000..004cfad65da8 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-hoisted/input/lazy.js @@ -0,0 +1,3 @@ +import { someLongExportName, aLongFunctionName } from './shared' + +export const fromLazy = () => `${someLongExportName}/${aLongFunctionName()}` diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-hoisted/input/shared.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-hoisted/input/shared.js new file mode 100644 index 000000000000..55ba316ee848 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-hoisted/input/shared.js @@ -0,0 +1,9 @@ +// Statically imported by `index.js` and dynamically by `lazy.js`, so it is exposed across a chunk +// boundary and its export object is really emitted even with scope hoisting on. + +export const someLongExportName = 'shared-1' +export const anotherLongExportName = 'shared-2' + +export function aLongFunctionName() { + return 'shared-fn' +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-hoisted/options.json b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-hoisted/options.json new file mode 100644 index 000000000000..8e711cc5db1d --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-hoisted/options.json @@ -0,0 +1,5 @@ +{ + "mangleExportNames": true, + "minify": true, + "scopeHoisting": true +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-magic-comments/input/index.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-magic-comments/input/index.js new file mode 100644 index 000000000000..d107d595c6ca --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-magic-comments/input/index.js @@ -0,0 +1,24 @@ +it('should keep the names of a module imported with webpackExports', async () => { + const { usedName, exportsInfo } = await import( + /* webpackExports: ["usedName", "exportsInfo"] */ './lazy' + ) + expect(usedName).toBe('used') + // The magic comment narrows which exports are *used*, but the namespace object still exposes + // them under their original names, so the module has to back off. + expect(exportsInfo.usedName.canMangle).toBe(false) + expect(exportsInfo.usedName.mangledName).toBe(null) +}) + +it('should keep the names of a module imported with turbopackExports', async () => { + const ns = await import( + /* turbopackExports: ["otherUsedName", "exportsInfo"] */ './lazy' + ) + expect(ns.otherUsedName).toBe('other-used') + expect(ns.exportsInfo.otherUsedName.canMangle).toBe(false) +}) + +it('should keep a plain dynamic import working', async () => { + const ns = await import('./lazy') + expect(ns.usedName).toBe('used') + expect(Object.keys(ns)).toContain('usedName') +}) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-magic-comments/input/lazy.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-magic-comments/input/lazy.js new file mode 100644 index 000000000000..b60403e95d97 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-magic-comments/input/lazy.js @@ -0,0 +1,12 @@ +// Reached only through `import()`. Even with a `webpackExports` / `turbopackExports` comment +// telling us which exports are used, the namespace object that `import()` resolves to is handed +// to user code and destructured by the *original* names, so this module must not be mangled. + +export const usedName = 'used' +export const otherUsedName = 'other-used' +export const unusedName = 'unused' + +export const exportsInfo = { + usedName: __webpack_exports_info__.usedName, + otherUsedName: __webpack_exports_info__.otherUsedName, +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-magic-comments/options.json b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-magic-comments/options.json new file mode 100644 index 000000000000..c06a1097d5b0 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-magic-comments/options.json @@ -0,0 +1,5 @@ +{ + "mangleExportNames": true, + "minify": true, + "scopeHoisting": false +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/consume.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/consume.js new file mode 100644 index 000000000000..beaf1f0d5583 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/consume.js @@ -0,0 +1,128 @@ +import { + exportNumber00, + exportNumber01, + exportNumber02, + exportNumber03, + exportNumber04, + exportNumber05, + exportNumber06, + exportNumber07, + exportNumber08, + exportNumber09, + exportNumber10, + exportNumber11, + exportNumber12, + exportNumber13, + exportNumber14, + exportNumber15, + exportNumber16, + exportNumber17, + exportNumber18, + exportNumber19, + exportNumber20, + exportNumber21, + exportNumber22, + exportNumber23, + exportNumber24, + exportNumber25, + exportNumber26, + exportNumber27, + exportNumber28, + exportNumber29, + exportNumber30, + exportNumber31, + exportNumber32, + exportNumber33, + exportNumber34, + exportNumber35, + exportNumber36, + exportNumber37, + exportNumber38, + exportNumber39, + exportNumber40, + exportNumber41, + exportNumber42, + exportNumber43, + exportNumber44, + exportNumber45, + exportNumber46, + exportNumber47, + exportNumber48, + exportNumber49, + exportNumber50, + exportNumber51, + exportNumber52, + exportNumber53, + exportNumber54, + exportNumber55, + exportNumber56, + exportNumber57, + exportNumber58, + exportNumber59, + exportsInfo, +} from './many-named' + +export const values = () => [ + exportNumber00, + exportNumber01, + exportNumber02, + exportNumber03, + exportNumber04, + exportNumber05, + exportNumber06, + exportNumber07, + exportNumber08, + exportNumber09, + exportNumber10, + exportNumber11, + exportNumber12, + exportNumber13, + exportNumber14, + exportNumber15, + exportNumber16, + exportNumber17, + exportNumber18, + exportNumber19, + exportNumber20, + exportNumber21, + exportNumber22, + exportNumber23, + exportNumber24, + exportNumber25, + exportNumber26, + exportNumber27, + exportNumber28, + exportNumber29, + exportNumber30, + exportNumber31, + exportNumber32, + exportNumber33, + exportNumber34, + exportNumber35, + exportNumber36, + exportNumber37, + exportNumber38, + exportNumber39, + exportNumber40, + exportNumber41, + exportNumber42, + exportNumber43, + exportNumber44, + exportNumber45, + exportNumber46, + exportNumber47, + exportNumber48, + exportNumber49, + exportNumber50, + exportNumber51, + exportNumber52, + exportNumber53, + exportNumber54, + exportNumber55, + exportNumber56, + exportNumber57, + exportNumber58, + exportNumber59, +] + +export { exportsInfo } diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/generate.mjs b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/generate.mjs new file mode 100644 index 000000000000..e5cf6a6f87ef --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/generate.mjs @@ -0,0 +1,45 @@ +// Not part of the test: regenerates the two 60-export modules and the consumer that imports each +// of them by name. Run from this directory: +// node generate.mjs +import { writeFileSync } from 'node:fs' + +const count = 60 +const names = Array.from( + { length: count }, + (_, i) => `exportNumber${String(i).padStart(2, '0')}` +) + +const moduleSource = (withInfo) => + [ + ...names.map((name, i) => { + const n = String(i).padStart(2, '0') + return `export const ${name} = 'value-${n}'` + }), + '', + 'export const exportsInfo = {', + ...names.map((name) => ` ${name}: __webpack_exports_info__.${name},`), + '}', + '', + ].join('\n') + +// Read through a namespace binding with a computed key, so this one has to keep its names. +writeFileSync('many.js', moduleSource()) +// Imported entirely by name, so this one is mangled with a two-character table. +writeFileSync('many-named.js', moduleSource()) + +writeFileSync( + 'consume.js', + [ + 'import {', + ...names.map((name) => ` ${name},`), + ' exportsInfo,', + "} from './many-named'", + '', + 'export const values = () => [', + ...names.map((name) => ` ${name},`), + ']', + '', + 'export { exportsInfo }', + '', + ].join('\n') +) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/index.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/index.js new file mode 100644 index 000000000000..d3b6cefea17b --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/index.js @@ -0,0 +1,40 @@ +import * as manyAsNamespace from './many' +import { values, exportsInfo as namedExportsInfo } from './consume' + +// 60 used exports don't fit the 54 single-character identifiers, so the table grows to two +// characters. + +it('should mangle a 60-export module into a two-character table', () => { + const entries = Object.entries(namedExportsInfo) + expect(entries.length).toBe(60) + + for (const [name, info] of entries) { + expect(info.canMangle).toBe(true) + expect(info.mangledName).not.toBe(name) + expect(info.mangledName.length).toBeLessThanOrEqual(2) + } + + // Every name gets a distinct key. + const keys = entries.map(([, info]) => info.mangledName) + expect(new Set(keys).size).toBe(60) + + // Some names still fit in one character: the table is only as long as it has to be, and open + // addressing never pushes a name past the chosen length. + expect(keys.some((key) => key.length === 1)).toBe(true) +}) + +it('should keep all 60 values correct through the mangled keys', () => { + const expected = Array.from( + { length: 60 }, + (_, i) => `value-${String(i).padStart(2, '0')}` + ) + expect(values()).toEqual(expected) +}) + +it('should keep the names of a module read with a computed key', () => { + for (let i = 0; i < 60; i++) { + const n = String(i).padStart(2, '0') + expect(manyAsNamespace[`exportNumber${n}`]).toBe(`value-${n}`) + } + expect(manyAsNamespace.exportsInfo.exportNumber00.canMangle).toBe(false) +}) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/many-named.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/many-named.js new file mode 100644 index 000000000000..cb050669ef81 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/many-named.js @@ -0,0 +1,123 @@ +export const exportNumber00 = 'value-00' +export const exportNumber01 = 'value-01' +export const exportNumber02 = 'value-02' +export const exportNumber03 = 'value-03' +export const exportNumber04 = 'value-04' +export const exportNumber05 = 'value-05' +export const exportNumber06 = 'value-06' +export const exportNumber07 = 'value-07' +export const exportNumber08 = 'value-08' +export const exportNumber09 = 'value-09' +export const exportNumber10 = 'value-10' +export const exportNumber11 = 'value-11' +export const exportNumber12 = 'value-12' +export const exportNumber13 = 'value-13' +export const exportNumber14 = 'value-14' +export const exportNumber15 = 'value-15' +export const exportNumber16 = 'value-16' +export const exportNumber17 = 'value-17' +export const exportNumber18 = 'value-18' +export const exportNumber19 = 'value-19' +export const exportNumber20 = 'value-20' +export const exportNumber21 = 'value-21' +export const exportNumber22 = 'value-22' +export const exportNumber23 = 'value-23' +export const exportNumber24 = 'value-24' +export const exportNumber25 = 'value-25' +export const exportNumber26 = 'value-26' +export const exportNumber27 = 'value-27' +export const exportNumber28 = 'value-28' +export const exportNumber29 = 'value-29' +export const exportNumber30 = 'value-30' +export const exportNumber31 = 'value-31' +export const exportNumber32 = 'value-32' +export const exportNumber33 = 'value-33' +export const exportNumber34 = 'value-34' +export const exportNumber35 = 'value-35' +export const exportNumber36 = 'value-36' +export const exportNumber37 = 'value-37' +export const exportNumber38 = 'value-38' +export const exportNumber39 = 'value-39' +export const exportNumber40 = 'value-40' +export const exportNumber41 = 'value-41' +export const exportNumber42 = 'value-42' +export const exportNumber43 = 'value-43' +export const exportNumber44 = 'value-44' +export const exportNumber45 = 'value-45' +export const exportNumber46 = 'value-46' +export const exportNumber47 = 'value-47' +export const exportNumber48 = 'value-48' +export const exportNumber49 = 'value-49' +export const exportNumber50 = 'value-50' +export const exportNumber51 = 'value-51' +export const exportNumber52 = 'value-52' +export const exportNumber53 = 'value-53' +export const exportNumber54 = 'value-54' +export const exportNumber55 = 'value-55' +export const exportNumber56 = 'value-56' +export const exportNumber57 = 'value-57' +export const exportNumber58 = 'value-58' +export const exportNumber59 = 'value-59' + +export const exportsInfo = { + exportNumber00: __webpack_exports_info__.exportNumber00, + exportNumber01: __webpack_exports_info__.exportNumber01, + exportNumber02: __webpack_exports_info__.exportNumber02, + exportNumber03: __webpack_exports_info__.exportNumber03, + exportNumber04: __webpack_exports_info__.exportNumber04, + exportNumber05: __webpack_exports_info__.exportNumber05, + exportNumber06: __webpack_exports_info__.exportNumber06, + exportNumber07: __webpack_exports_info__.exportNumber07, + exportNumber08: __webpack_exports_info__.exportNumber08, + exportNumber09: __webpack_exports_info__.exportNumber09, + exportNumber10: __webpack_exports_info__.exportNumber10, + exportNumber11: __webpack_exports_info__.exportNumber11, + exportNumber12: __webpack_exports_info__.exportNumber12, + exportNumber13: __webpack_exports_info__.exportNumber13, + exportNumber14: __webpack_exports_info__.exportNumber14, + exportNumber15: __webpack_exports_info__.exportNumber15, + exportNumber16: __webpack_exports_info__.exportNumber16, + exportNumber17: __webpack_exports_info__.exportNumber17, + exportNumber18: __webpack_exports_info__.exportNumber18, + exportNumber19: __webpack_exports_info__.exportNumber19, + exportNumber20: __webpack_exports_info__.exportNumber20, + exportNumber21: __webpack_exports_info__.exportNumber21, + exportNumber22: __webpack_exports_info__.exportNumber22, + exportNumber23: __webpack_exports_info__.exportNumber23, + exportNumber24: __webpack_exports_info__.exportNumber24, + exportNumber25: __webpack_exports_info__.exportNumber25, + exportNumber26: __webpack_exports_info__.exportNumber26, + exportNumber27: __webpack_exports_info__.exportNumber27, + exportNumber28: __webpack_exports_info__.exportNumber28, + exportNumber29: __webpack_exports_info__.exportNumber29, + exportNumber30: __webpack_exports_info__.exportNumber30, + exportNumber31: __webpack_exports_info__.exportNumber31, + exportNumber32: __webpack_exports_info__.exportNumber32, + exportNumber33: __webpack_exports_info__.exportNumber33, + exportNumber34: __webpack_exports_info__.exportNumber34, + exportNumber35: __webpack_exports_info__.exportNumber35, + exportNumber36: __webpack_exports_info__.exportNumber36, + exportNumber37: __webpack_exports_info__.exportNumber37, + exportNumber38: __webpack_exports_info__.exportNumber38, + exportNumber39: __webpack_exports_info__.exportNumber39, + exportNumber40: __webpack_exports_info__.exportNumber40, + exportNumber41: __webpack_exports_info__.exportNumber41, + exportNumber42: __webpack_exports_info__.exportNumber42, + exportNumber43: __webpack_exports_info__.exportNumber43, + exportNumber44: __webpack_exports_info__.exportNumber44, + exportNumber45: __webpack_exports_info__.exportNumber45, + exportNumber46: __webpack_exports_info__.exportNumber46, + exportNumber47: __webpack_exports_info__.exportNumber47, + exportNumber48: __webpack_exports_info__.exportNumber48, + exportNumber49: __webpack_exports_info__.exportNumber49, + exportNumber50: __webpack_exports_info__.exportNumber50, + exportNumber51: __webpack_exports_info__.exportNumber51, + exportNumber52: __webpack_exports_info__.exportNumber52, + exportNumber53: __webpack_exports_info__.exportNumber53, + exportNumber54: __webpack_exports_info__.exportNumber54, + exportNumber55: __webpack_exports_info__.exportNumber55, + exportNumber56: __webpack_exports_info__.exportNumber56, + exportNumber57: __webpack_exports_info__.exportNumber57, + exportNumber58: __webpack_exports_info__.exportNumber58, + exportNumber59: __webpack_exports_info__.exportNumber59, +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/many.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/many.js new file mode 100644 index 000000000000..cb050669ef81 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/input/many.js @@ -0,0 +1,123 @@ +export const exportNumber00 = 'value-00' +export const exportNumber01 = 'value-01' +export const exportNumber02 = 'value-02' +export const exportNumber03 = 'value-03' +export const exportNumber04 = 'value-04' +export const exportNumber05 = 'value-05' +export const exportNumber06 = 'value-06' +export const exportNumber07 = 'value-07' +export const exportNumber08 = 'value-08' +export const exportNumber09 = 'value-09' +export const exportNumber10 = 'value-10' +export const exportNumber11 = 'value-11' +export const exportNumber12 = 'value-12' +export const exportNumber13 = 'value-13' +export const exportNumber14 = 'value-14' +export const exportNumber15 = 'value-15' +export const exportNumber16 = 'value-16' +export const exportNumber17 = 'value-17' +export const exportNumber18 = 'value-18' +export const exportNumber19 = 'value-19' +export const exportNumber20 = 'value-20' +export const exportNumber21 = 'value-21' +export const exportNumber22 = 'value-22' +export const exportNumber23 = 'value-23' +export const exportNumber24 = 'value-24' +export const exportNumber25 = 'value-25' +export const exportNumber26 = 'value-26' +export const exportNumber27 = 'value-27' +export const exportNumber28 = 'value-28' +export const exportNumber29 = 'value-29' +export const exportNumber30 = 'value-30' +export const exportNumber31 = 'value-31' +export const exportNumber32 = 'value-32' +export const exportNumber33 = 'value-33' +export const exportNumber34 = 'value-34' +export const exportNumber35 = 'value-35' +export const exportNumber36 = 'value-36' +export const exportNumber37 = 'value-37' +export const exportNumber38 = 'value-38' +export const exportNumber39 = 'value-39' +export const exportNumber40 = 'value-40' +export const exportNumber41 = 'value-41' +export const exportNumber42 = 'value-42' +export const exportNumber43 = 'value-43' +export const exportNumber44 = 'value-44' +export const exportNumber45 = 'value-45' +export const exportNumber46 = 'value-46' +export const exportNumber47 = 'value-47' +export const exportNumber48 = 'value-48' +export const exportNumber49 = 'value-49' +export const exportNumber50 = 'value-50' +export const exportNumber51 = 'value-51' +export const exportNumber52 = 'value-52' +export const exportNumber53 = 'value-53' +export const exportNumber54 = 'value-54' +export const exportNumber55 = 'value-55' +export const exportNumber56 = 'value-56' +export const exportNumber57 = 'value-57' +export const exportNumber58 = 'value-58' +export const exportNumber59 = 'value-59' + +export const exportsInfo = { + exportNumber00: __webpack_exports_info__.exportNumber00, + exportNumber01: __webpack_exports_info__.exportNumber01, + exportNumber02: __webpack_exports_info__.exportNumber02, + exportNumber03: __webpack_exports_info__.exportNumber03, + exportNumber04: __webpack_exports_info__.exportNumber04, + exportNumber05: __webpack_exports_info__.exportNumber05, + exportNumber06: __webpack_exports_info__.exportNumber06, + exportNumber07: __webpack_exports_info__.exportNumber07, + exportNumber08: __webpack_exports_info__.exportNumber08, + exportNumber09: __webpack_exports_info__.exportNumber09, + exportNumber10: __webpack_exports_info__.exportNumber10, + exportNumber11: __webpack_exports_info__.exportNumber11, + exportNumber12: __webpack_exports_info__.exportNumber12, + exportNumber13: __webpack_exports_info__.exportNumber13, + exportNumber14: __webpack_exports_info__.exportNumber14, + exportNumber15: __webpack_exports_info__.exportNumber15, + exportNumber16: __webpack_exports_info__.exportNumber16, + exportNumber17: __webpack_exports_info__.exportNumber17, + exportNumber18: __webpack_exports_info__.exportNumber18, + exportNumber19: __webpack_exports_info__.exportNumber19, + exportNumber20: __webpack_exports_info__.exportNumber20, + exportNumber21: __webpack_exports_info__.exportNumber21, + exportNumber22: __webpack_exports_info__.exportNumber22, + exportNumber23: __webpack_exports_info__.exportNumber23, + exportNumber24: __webpack_exports_info__.exportNumber24, + exportNumber25: __webpack_exports_info__.exportNumber25, + exportNumber26: __webpack_exports_info__.exportNumber26, + exportNumber27: __webpack_exports_info__.exportNumber27, + exportNumber28: __webpack_exports_info__.exportNumber28, + exportNumber29: __webpack_exports_info__.exportNumber29, + exportNumber30: __webpack_exports_info__.exportNumber30, + exportNumber31: __webpack_exports_info__.exportNumber31, + exportNumber32: __webpack_exports_info__.exportNumber32, + exportNumber33: __webpack_exports_info__.exportNumber33, + exportNumber34: __webpack_exports_info__.exportNumber34, + exportNumber35: __webpack_exports_info__.exportNumber35, + exportNumber36: __webpack_exports_info__.exportNumber36, + exportNumber37: __webpack_exports_info__.exportNumber37, + exportNumber38: __webpack_exports_info__.exportNumber38, + exportNumber39: __webpack_exports_info__.exportNumber39, + exportNumber40: __webpack_exports_info__.exportNumber40, + exportNumber41: __webpack_exports_info__.exportNumber41, + exportNumber42: __webpack_exports_info__.exportNumber42, + exportNumber43: __webpack_exports_info__.exportNumber43, + exportNumber44: __webpack_exports_info__.exportNumber44, + exportNumber45: __webpack_exports_info__.exportNumber45, + exportNumber46: __webpack_exports_info__.exportNumber46, + exportNumber47: __webpack_exports_info__.exportNumber47, + exportNumber48: __webpack_exports_info__.exportNumber48, + exportNumber49: __webpack_exports_info__.exportNumber49, + exportNumber50: __webpack_exports_info__.exportNumber50, + exportNumber51: __webpack_exports_info__.exportNumber51, + exportNumber52: __webpack_exports_info__.exportNumber52, + exportNumber53: __webpack_exports_info__.exportNumber53, + exportNumber54: __webpack_exports_info__.exportNumber54, + exportNumber55: __webpack_exports_info__.exportNumber55, + exportNumber56: __webpack_exports_info__.exportNumber56, + exportNumber57: __webpack_exports_info__.exportNumber57, + exportNumber58: __webpack_exports_info__.exportNumber58, + exportNumber59: __webpack_exports_info__.exportNumber59, +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/options.json b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/options.json new file mode 100644 index 000000000000..8e711cc5db1d --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-many-exports/options.json @@ -0,0 +1,5 @@ +{ + "mangleExportNames": true, + "minify": true, + "scopeHoisting": true +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-minify-off/input/index.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-minify-off/input/index.js new file mode 100644 index 000000000000..53e80b898f03 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-minify-off/input/index.js @@ -0,0 +1,37 @@ +// `mangleExportNames` is on, but minification is off. Mangling and minification are independent +// options — this fixture proves that mangling still happens without minification, the same way it +// would with it (see `mangle-basic`). + +import { + a, + b, + foo, + reallyLongExportName, + anotherVeryLongExportName, + shortFn, + thisIsAVeryLongFunctionName, + exportsInfo, +} from './named-exports' + +it('should keep the values correct with minification disabled', () => { + expect(a).toBe('short-a') + expect(b).toBe('short-b') + expect(foo).toBe('short-foo') + expect(reallyLongExportName).toBe('long-name-1') + expect(anotherVeryLongExportName).toBe('long-name-2') + expect(shortFn()).toBe('short-fn') + expect(thisIsAVeryLongFunctionName()).toBe('long-fn') +}) + +it('should still mangle the exported names with minification disabled', () => { + expect(exportsInfo.reallyLongExportName.canMangle).toBe(true) + expect(exportsInfo.reallyLongExportName.mangledName).toEqual( + expect.any(String) + ) + expect( + exportsInfo.reallyLongExportName.mangledName.length + ).toBeLessThanOrEqual(2) + expect(exportsInfo.thisIsAVeryLongFunctionName.mangledName).not.toBe( + 'thisIsAVeryLongFunctionName' + ) +}) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-minify-off/input/named-exports.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-minify-off/input/named-exports.js new file mode 100644 index 000000000000..2d9a96e3cce4 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-minify-off/input/named-exports.js @@ -0,0 +1,23 @@ +// Every export here is imported by name, so the keys this module emits are only ever read by +// generated code and can be shortened. + +export const a = 'short-a' +export const b = 'short-b' +export const foo = 'short-foo' +export const reallyLongExportName = 'long-name-1' +export const anotherVeryLongExportName = 'long-name-2' + +export function shortFn() { + return 'short-fn' +} + +export function thisIsAVeryLongFunctionName() { + return 'long-fn' +} + +export const exportsInfo = { + a: __webpack_exports_info__.a, + reallyLongExportName: __webpack_exports_info__.reallyLongExportName, + thisIsAVeryLongFunctionName: + __webpack_exports_info__.thisIsAVeryLongFunctionName, +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-minify-off/options.json b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-minify-off/options.json new file mode 100644 index 000000000000..84cb4a0f04b4 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-minify-off/options.json @@ -0,0 +1,5 @@ +{ + "mangleExportNames": true, + "minify": false, + "scopeHoisting": false +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-object-prop/input/index.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-object-prop/input/index.js new file mode 100644 index 000000000000..dd7696ab5d41 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-object-prop/input/index.js @@ -0,0 +1,38 @@ +import { + toString, + valueOf, + constructor, + __proto__, + a, + $1, + __1, + aVeryLongExportNameIndeed, + exportsInfo, +} from './lib' + +it('should mangle exports whose names shadow Object.prototype members', () => { + expect(toString).toBe('toString-value') + expect(valueOf).toBe('valueOf-value') + expect(constructor).toBe('constructor-value') + expect(__proto__).toBe('proto-value') + expect(aVeryLongExportNameIndeed).toBe('long') +}) + +it('should keep short names and mangle the rest', () => { + expect(a).toBe('single char') + expect($1).toBe('double char') + expect(__1).toBe('3 chars') + + // `a` and `$` are single valid identifiers in the table's alphabet, so they keep themselves. + expect(exportsInfo.a.mangledName).toBe('a') + // `$1` and `__1` are longer than the table's identifier length, so they get hashed like any + // other name — including `__1`, which is not even representable in the encoding (trailing + // "zero" characters are rejected as degenerate). + expect(exportsInfo.$1.mangledName).not.toBe('$1') + expect(exportsInfo.__1.mangledName).not.toBe('__1') +}) + +it('should give every export a distinct key', () => { + const keys = Object.values(exportsInfo).map((e) => e.mangledName) + expect(new Set(keys).size).toBe(keys.length) +}) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-object-prop/input/lib.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-object-prop/input/lib.js new file mode 100644 index 000000000000..41bf7f31ae6a --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-object-prop/input/lib.js @@ -0,0 +1,20 @@ +// Export names that are awkward for a mangling table: names that collide with +// `Object.prototype` members, and names that are already short enough to be kept. + +export const toString = 'toString-value' +export const valueOf = 'valueOf-value' +export const constructor = 'constructor-value' +export const __proto__ = 'proto-value' +export const a = 'single char' +export const $1 = 'double char' +export const __1 = '3 chars' +export const aVeryLongExportNameIndeed = 'long' + +export const exportsInfo = { + toString: __webpack_exports_info__.toString, + valueOf: __webpack_exports_info__.valueOf, + a: __webpack_exports_info__.a, + $1: __webpack_exports_info__.$1, + __1: __webpack_exports_info__.__1, + aVeryLongExportNameIndeed: __webpack_exports_info__.aVeryLongExportNameIndeed, +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-object-prop/options.json b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-object-prop/options.json new file mode 100644 index 000000000000..c06a1097d5b0 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-object-prop/options.json @@ -0,0 +1,5 @@ +{ + "mangleExportNames": true, + "minify": true, + "scopeHoisting": false +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-off/input/index.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-off/input/index.js new file mode 100644 index 000000000000..2d26cb76be57 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-off/input/index.js @@ -0,0 +1,35 @@ +// The control for `mangle-basic`: identical input, `mangleExportNames` off. Same observable +// behaviour, and no export key is renamed. + +import { + a, + b, + foo, + reallyLongExportName, + anotherVeryLongExportName, + shortFn, + thisIsAVeryLongFunctionName, + exportsInfo, +} from './named-exports' + +it('should keep the values correct with mangling disabled', () => { + expect(a).toBe('short-a') + expect(b).toBe('short-b') + expect(foo).toBe('short-foo') + expect(reallyLongExportName).toBe('long-name-1') + expect(anotherVeryLongExportName).toBe('long-name-2') + expect(shortFn()).toBe('short-fn') + expect(thisIsAVeryLongFunctionName()).toBe('long-fn') +}) + +it('should report canMangle: false and mangledName: null when disabled', () => { + // With the option off, `canMangle`/`mangledName` are still present (the shape of + // `__webpack_exports_info__` no longer depends on whether mangling is enabled at all), but + // report that nothing was mangled: `canMangle` is always false, and `mangledName` is only ever + // a string when `canMangle` is true, so it's `null` here. + expect(exportsInfo.a.used).toBe(true) + expect(exportsInfo.a.canMangle).toBe(false) + expect(exportsInfo.a.mangledName).toBe(null) + expect(exportsInfo.reallyLongExportName.canMangle).toBe(false) + expect(exportsInfo.reallyLongExportName.mangledName).toBe(null) +}) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-off/input/named-exports.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-off/input/named-exports.js new file mode 100644 index 000000000000..2d9a96e3cce4 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-off/input/named-exports.js @@ -0,0 +1,23 @@ +// Every export here is imported by name, so the keys this module emits are only ever read by +// generated code and can be shortened. + +export const a = 'short-a' +export const b = 'short-b' +export const foo = 'short-foo' +export const reallyLongExportName = 'long-name-1' +export const anotherVeryLongExportName = 'long-name-2' + +export function shortFn() { + return 'short-fn' +} + +export function thisIsAVeryLongFunctionName() { + return 'long-fn' +} + +export const exportsInfo = { + a: __webpack_exports_info__.a, + reallyLongExportName: __webpack_exports_info__.reallyLongExportName, + thisIsAVeryLongFunctionName: + __webpack_exports_info__.thisIsAVeryLongFunctionName, +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-off/options.json b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-off/options.json new file mode 100644 index 000000000000..693b36a63639 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-off/options.json @@ -0,0 +1,5 @@ +{ + "mangleExportNames": false, + "minify": true, + "scopeHoisting": false +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/input/a.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/input/a.js new file mode 100644 index 000000000000..7ba2297fc0e1 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/input/a.js @@ -0,0 +1,15 @@ +export const veryLongOriginalExportName = 'from-a' + +export function anotherLongFunctionName() { + return 'func-a' +} + +export default function () { + return 'default-from-a' +} + +export const exportsInfo = { + veryLongOriginalExportName: + __webpack_exports_info__.veryLongOriginalExportName, + anotherLongFunctionName: __webpack_exports_info__.anotherLongFunctionName, +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/input/b.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/input/b.js new file mode 100644 index 000000000000..69c4553e6b1c --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/input/b.js @@ -0,0 +1,6 @@ +export { + veryLongOriginalExportName as renamedInMiddleLayer, + anotherLongFunctionName as middleLayerFunction, + exportsInfo, +} from './a' +export { default as defaultFromA } from './a' diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/input/c.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/input/c.js new file mode 100644 index 000000000000..b3be560116f7 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/input/c.js @@ -0,0 +1,12 @@ +export { + renamedInMiddleLayer as finalExportName, + middleLayerFunction as finalFunctionName, + defaultFromA as finalDefaultExport, + exportsInfo, +} from './b' + +export const localInC = 'local-c' + +export default function () { + return 'default-from-c' +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/input/index.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/input/index.js new file mode 100644 index 000000000000..f0c654344731 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/input/index.js @@ -0,0 +1,40 @@ +import { + finalExportName, + finalFunctionName, + finalDefaultExport, + localInC, + exportsInfo, +} from './c' +import defaultFromC from './c' +import { renamedInMiddleLayer, middleLayerFunction } from './b' +import { veryLongOriginalExportName, anotherLongFunctionName } from './a' + +it('should carry values through a re-export chain', () => { + // Each module in the chain mangles its own keys independently; every hop has to resolve to the + // key of the module that actually produces the binding. + expect(finalExportName).toBe('from-a') + expect(finalFunctionName()).toBe('func-a') + expect(localInC).toBe('local-c') +}) + +it('should carry default exports through a re-export chain', () => { + expect(finalDefaultExport()).toBe('default-from-a') + expect(defaultFromC()).toBe('default-from-c') +}) + +it('should support importing from the middle and the source of the chain', () => { + expect(renamedInMiddleLayer).toBe('from-a') + expect(middleLayerFunction()).toBe('func-a') + expect(veryLongOriginalExportName).toBe('from-a') + expect(anotherLongFunctionName()).toBe('func-a') +}) + +it('should mangle the source module of the chain', () => { + expect(exportsInfo.veryLongOriginalExportName.canMangle).toBe(true) + expect(exportsInfo.veryLongOriginalExportName.mangledName).not.toBe( + 'veryLongOriginalExportName' + ) + expect(exportsInfo.anotherLongFunctionName.mangledName).not.toBe( + exportsInfo.veryLongOriginalExportName.mangledName + ) +}) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/options.json b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/options.json new file mode 100644 index 000000000000..c06a1097d5b0 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/exports/mangle-reexport-chain/options.json @@ -0,0 +1,5 @@ +{ + "mangleExportNames": true, + "minify": true, + "scopeHoisting": false +} diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/resolving/import-meta-glob/input/index.js b/turbopack/crates/turbopack-tests/tests/execution/turbopack/resolving/import-meta-glob/input/index.js index 0e84042aa8ca..03aa27c4b921 100644 --- a/turbopack/crates/turbopack-tests/tests/execution/turbopack/resolving/import-meta-glob/input/index.js +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/resolving/import-meta-glob/input/index.js @@ -63,6 +63,16 @@ it('should support multiple patterns across directories', () => { expect(multiModules['./other/baz.js'].default).toBe('baz') }) +// The result keys stay logical while module resolution follows the directory symlink. +const symlinkModules = import.meta.glob('./linked/*.js', { eager: true }) + +it('should resolve modules through a symlink while preserving logical keys', () => { + const keys = Object.keys(symlinkModules).sort() + expect(keys).toEqual(['./linked/bar.js', './linked/foo.js']) + expect(symlinkModules['./linked/foo.js'].default).toBe('foo') + expect(symlinkModules['./linked/bar.js'].default).toBe('bar') +}) + // import: '*' (namespace import) — should return the whole module namespace // Uses ./other/*.js to avoid colliding with the eager test above (same pattern + eager + no import) const namespaceModules = import.meta.glob('./other/*.js', { @@ -116,6 +126,8 @@ it('should include dotfile directories with wildcard patterns', () => { './CaseDir/module-lower.js', './dir/bar.js', './dir/foo.js', + './linked/bar.js', + './linked/foo.js', './other/baz.js', ]) }) diff --git a/turbopack/crates/turbopack-tests/tests/execution/turbopack/resolving/import-meta-glob/input/linked b/turbopack/crates/turbopack-tests/tests/execution/turbopack/resolving/import-meta-glob/input/linked new file mode 120000 index 000000000000..87245193225f --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/execution/turbopack/resolving/import-meta-glob/input/linked @@ -0,0 +1 @@ +dir \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot.rs b/turbopack/crates/turbopack-tests/tests/snapshot.rs index 9a6e3cfc1a8e..9f6a97342939 100644 --- a/turbopack/crates/turbopack-tests/tests/snapshot.rs +++ b/turbopack/crates/turbopack-tests/tests/snapshot.rs @@ -96,6 +96,8 @@ struct SnapshotOptions { #[serde(default)] cjs_tree_shaking: bool, #[serde(default)] + mangle_export_names: bool, + #[serde(default)] cjs_scope_hoisting: bool, #[serde(default)] cross_module_constants: bool, @@ -145,6 +147,7 @@ impl Default for SnapshotOptions { remove_unused_imports: false, remove_unused_exports: false, cjs_tree_shaking: false, + mangle_export_names: false, cjs_scope_hoisting: false, cross_module_constants: false, scope_hoisting: false, @@ -422,6 +425,7 @@ async fn run_test_operation(resource: RcStr) -> Result> { ignore_dynamic_requests: true, infer_module_side_effects: true, cjs_tree_shaking: options.cjs_tree_shaking, + mangle_export_names: options.mangle_export_names, cjs_scope_hoisting: options.cjs_scope_hoisting, cross_module_constants: options.cross_module_constants, enable_exports_info_inlining: true, diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/escaping.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/escaping.js new file mode 100644 index 000000000000..82e98c0a1e99 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/escaping.js @@ -0,0 +1,3 @@ +// A namespace object of this module escapes, so it keeps its original export names. +export const someLongExportName = 'long-1' +export const anotherLongExportName = 'long-2' diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/index.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/index.js new file mode 100644 index 000000000000..0c71ff2925df --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/index.js @@ -0,0 +1,6 @@ +import * as escaping from './escaping.js' +import { someLongExportName, anotherLongExportName } from './mangled.js' + +const leak = () => escaping + +console.log(leak(), someLongExportName, anotherLongExportName) diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/mangled.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/mangled.js new file mode 100644 index 000000000000..9022b862b05f --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/mangled.js @@ -0,0 +1,3 @@ +// Nothing escapes here, so this module in the same graph is still mangled. +export const someLongExportName = 'mangled-1' +export const anotherLongExportName = 'mangled-2' diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/options.json b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/options.json new file mode 100644 index 000000000000..d71ee065972b --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/options.json @@ -0,0 +1,12 @@ +{ + "runtime": "NodeJs", + "mangleExportNames": true, + "removeUnusedExports": true, + "removeUnusedImports": true, + "followReexports": true, + "minifyType": { + "Minify": { + "mangle": "optimal-size" + } + } +} diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/1jsg_tests_snapshot_mangle-exports_escaping-namespace_input_1ysta134r3dyz._.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/1jsg_tests_snapshot_mangle-exports_escaping-namespace_input_1ysta134r3dyz._.js new file mode 100644 index 000000000000..5bb5aa3e8c38 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/1jsg_tests_snapshot_mangle-exports_escaping-namespace_input_1ysta134r3dyz._.js @@ -0,0 +1,3 @@ +module.exports=["[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/escaping.js [test] (ecmascript)",t=>{"use strict";t.s(["anotherLongExportName",0,"long-2","someLongExportName",0,"long-1"])},"[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/index.js [test] (ecmascript)",t=>{"use strict";var s=t.i("[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/escaping.js [test] (ecmascript)"),e=t.i("[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/mangled.js [test] (ecmascript)");console.log(s,e.z,e.T),t.s([])},"[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/mangled.js [test] (ecmascript)",t=>{"use strict";t.s(["T",0,"mangled-2","z",0,"mangled-1"])}]; + +//# sourceMappingURL=1jsg_tests_snapshot_mangle-exports_escaping-namespace_input_1ysta134r3dyz._.js.map \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/1jsg_tests_snapshot_mangle-exports_escaping-namespace_input_1ysta134r3dyz._.js.map b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/1jsg_tests_snapshot_mangle-exports_escaping-namespace_input_1ysta134r3dyz._.js.map new file mode 100644 index 000000000000..e6eceb194eb1 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/1jsg_tests_snapshot_mangle-exports_escaping-namespace_input_1ysta134r3dyz._.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["turbopack:///[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/escaping.js","turbopack:///[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/index.js","turbopack:///[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/mangled.js"],"sourcesContent":["// A namespace object of this module escapes, so it keeps its original export names.\nexport const someLongExportName = 'long-1'\nexport const anotherLongExportName = 'long-2'\n","import * as escaping from './escaping.js'\nimport { someLongExportName, anotherLongExportName } from './mangled.js'\n\nconst leak = () => escaping\n\nconsole.log(leak(), someLongExportName, anotherLongExportName)\n","// Nothing escapes here, so this module in the same graph is still mangled.\nexport const someLongExportName = 'mangled-1'\nexport const anotherLongExportName = 'mangled-2'\n"],"names":["someLongExportName","anotherLongExportName","leak","imported module [project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/escaping.js [test] (ecmascript)","console","log"],"mappings":"oMAEqC,gCADH,8JCDlC,IAAA,EAAA,EAAA,CAAA,CAAA,qIACA,EAAA,EAAA,CAAA,CAAA,oIAIAI,QAAQC,GAAG,CAFQF,AAEPD,EAAQ,EAAA,CAAkB,CAAE,EAAA,CAAqB,0KCHxB,kBADH"} \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/[turbopack]_runtime.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/[turbopack]_runtime.js new file mode 100644 index 000000000000..af1ff6b42072 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/[turbopack]_runtime.js @@ -0,0 +1,4 @@ +var RUNTIME_PUBLIC_PATH = "output/[turbopack]_runtime.js"; +var RELATIVE_ROOT_PATH = "../../../../../../.."; +var ASSET_PREFIX = "/"; +// Dummy runtime \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/[turbopack]_runtime.js.map b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/[turbopack]_runtime.js.map new file mode 100644 index 000000000000..c15d7ec00382 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/[turbopack]_runtime.js.map @@ -0,0 +1,5 @@ +{ + "version": 3, + "sources": [], + "sections": [] +} \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/index.entry.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/index.entry.js new file mode 100644 index 000000000000..8e2b0248e4a9 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/index.entry.js @@ -0,0 +1,4 @@ +var R=require("./[turbopack]_runtime.js")("output/index.entry.js") +R.c("output/1jsg_tests_snapshot_mangle-exports_escaping-namespace_input_1ysta134r3dyz._.js") +R.m("[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/index.js [test] (ecmascript)") +module.exports=R.m("[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/input/index.js [test] (ecmascript)").exports \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/index.entry.js.map b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/index.entry.js.map new file mode 100644 index 000000000000..c15d7ec00382 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/escaping-namespace/output/index.entry.js.map @@ -0,0 +1,5 @@ +{ + "version": 3, + "sources": [], + "sections": [] +} \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/input/index.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/input/index.js new file mode 100644 index 000000000000..5909d8dcddd1 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/input/index.js @@ -0,0 +1,15 @@ +import { + a, + bb, + someLongExportName, + anotherLongExportName, + aLongFunctionName, +} from './lib.js' + +console.log( + a, + bb, + someLongExportName, + anotherLongExportName, + aLongFunctionName() +) diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/input/lib.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/input/lib.js new file mode 100644 index 000000000000..7b94ef2bf30e --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/input/lib.js @@ -0,0 +1,9 @@ +export const a = 'a' +export const bb = 'bb' +export const someLongExportName = 'long-1' +export const anotherLongExportName = 'long-2' +export const unusedLongExportName = 'unused' + +export function aLongFunctionName() { + return 'fn' +} diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/options.json b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/options.json new file mode 100644 index 000000000000..d71ee065972b --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/options.json @@ -0,0 +1,12 @@ +{ + "runtime": "NodeJs", + "mangleExportNames": true, + "removeUnusedExports": true, + "removeUnusedImports": true, + "followReexports": true, + "minifyType": { + "Minify": { + "mangle": "optimal-size" + } + } +} diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/1jsg_tests_snapshot_mangle-exports_named-and-preserved_input_0pbreftkp_5pc._.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/1jsg_tests_snapshot_mangle-exports_named-and-preserved_input_0pbreftkp_5pc._.js new file mode 100644 index 000000000000..722ac517a65b --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/1jsg_tests_snapshot_mangle-exports_named-and-preserved_input_0pbreftkp_5pc._.js @@ -0,0 +1,3 @@ +module.exports=["[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/input/index.js [test] (ecmascript)",t=>{"use strict";var s=t.i("[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/input/lib.js [test] (ecmascript)");console.log(s.a,s.$,s.z,s.T,(0,s.X)()),t.s([])},"[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/input/lib.js [test] (ecmascript)",t=>{"use strict";t.s(["a",0,"a","X",0,function(){return"fn"},"T",0,"long-2","$",0,"bb","z",0,"long-1"])}]; + +//# sourceMappingURL=1jsg_tests_snapshot_mangle-exports_named-and-preserved_input_0pbreftkp_5pc._.js.map \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/1jsg_tests_snapshot_mangle-exports_named-and-preserved_input_0pbreftkp_5pc._.js.map b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/1jsg_tests_snapshot_mangle-exports_named-and-preserved_input_0pbreftkp_5pc._.js.map new file mode 100644 index 000000000000..eb20fdbc2b38 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/1jsg_tests_snapshot_mangle-exports_named-and-preserved_input_0pbreftkp_5pc._.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["turbopack:///[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/input/index.js","turbopack:///[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/input/lib.js"],"sourcesContent":["import {\n a,\n bb,\n someLongExportName,\n anotherLongExportName,\n aLongFunctionName,\n} from './lib.js'\n\nconsole.log(\n a,\n bb,\n someLongExportName,\n anotherLongExportName,\n aLongFunctionName()\n)\n","export const a = 'a'\nexport const bb = 'bb'\nexport const someLongExportName = 'long-1'\nexport const anotherLongExportName = 'long-2'\nexport const unusedLongExportName = 'unused'\n\nexport function aLongFunctionName() {\n return 'fn'\n}\n"],"names":["console","log","a","bb","someLongExportName","anotherLongExportName","unusedLongExportName","aLongFunctionName"],"mappings":"mKAAA,IAAA,EAAA,EAAA,CAAA,CAAA,iIAQAA,QAAQC,GAAG,CACT,EAAA,CAAC,CACD,EAAA,CAAE,CACF,EAAA,CAAkB,CAClB,EAAA,CAAqB,CACrB,CAAA,EAAA,EAAA,CAAA,AAAiB,0KCbF,UAMV,SAASM,EACd,MAAO,IACT,QALqC,eAFnB,WACgB"} \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/[turbopack]_runtime.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/[turbopack]_runtime.js new file mode 100644 index 000000000000..af1ff6b42072 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/[turbopack]_runtime.js @@ -0,0 +1,4 @@ +var RUNTIME_PUBLIC_PATH = "output/[turbopack]_runtime.js"; +var RELATIVE_ROOT_PATH = "../../../../../../.."; +var ASSET_PREFIX = "/"; +// Dummy runtime \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/[turbopack]_runtime.js.map b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/[turbopack]_runtime.js.map new file mode 100644 index 000000000000..c15d7ec00382 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/[turbopack]_runtime.js.map @@ -0,0 +1,5 @@ +{ + "version": 3, + "sources": [], + "sections": [] +} \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/index.entry.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/index.entry.js new file mode 100644 index 000000000000..4e7478e4b86e --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/index.entry.js @@ -0,0 +1,4 @@ +var R=require("./[turbopack]_runtime.js")("output/index.entry.js") +R.c("output/1jsg_tests_snapshot_mangle-exports_named-and-preserved_input_0pbreftkp_5pc._.js") +R.m("[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/input/index.js [test] (ecmascript)") +module.exports=R.m("[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/input/index.js [test] (ecmascript)").exports \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/index.entry.js.map b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/index.entry.js.map new file mode 100644 index 000000000000..c15d7ec00382 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/named-and-preserved/output/index.entry.js.map @@ -0,0 +1,5 @@ +{ + "version": 3, + "sources": [], + "sections": [] +} \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/input/index.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/input/index.js new file mode 100644 index 000000000000..580ba9a7cf99 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/input/index.js @@ -0,0 +1,3 @@ +import { aLongFunctionNameNobodyWantsInTheBundle } from './lone' + +console.log(aLongFunctionNameNobodyWantsInTheBundle()) diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/input/lone.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/input/lone.js new file mode 100644 index 000000000000..72ac87843d5f --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/input/lone.js @@ -0,0 +1,6 @@ +// The only export of this module. It is emitted under the fixed single-export key rather than a +// hashed one, so that every single-export module in the graph produces the same `.f` byte sequence +// and gzip can share it. Visible in the committed output below. +export function aLongFunctionNameNobodyWantsInTheBundle() { + return 'lone' +} diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/options.json b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/options.json new file mode 100644 index 000000000000..d71ee065972b --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/options.json @@ -0,0 +1,12 @@ +{ + "runtime": "NodeJs", + "mangleExportNames": true, + "removeUnusedExports": true, + "removeUnusedImports": true, + "followReexports": true, + "minifyType": { + "Minify": { + "mangle": "optimal-size" + } + } +} diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/0_9x_turbopack-tests_tests_snapshot_mangle-exports_single-export_input_1mf78g4ogdhgv._.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/0_9x_turbopack-tests_tests_snapshot_mangle-exports_single-export_input_1mf78g4ogdhgv._.js new file mode 100644 index 000000000000..06ed5e88a924 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/0_9x_turbopack-tests_tests_snapshot_mangle-exports_single-export_input_1mf78g4ogdhgv._.js @@ -0,0 +1,3 @@ +module.exports=["[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/input/index.js [test] (ecmascript)",t=>{"use strict";console.log((0,t.i("[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/input/lone.js [test] (ecmascript)").f)()),t.s([])},"[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/input/lone.js [test] (ecmascript)",t=>{"use strict";t.s(["f",0,function(){return"lone"}])}]; + +//# sourceMappingURL=0_9x_turbopack-tests_tests_snapshot_mangle-exports_single-export_input_1mf78g4ogdhgv._.js.map \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/0_9x_turbopack-tests_tests_snapshot_mangle-exports_single-export_input_1mf78g4ogdhgv._.js.map b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/0_9x_turbopack-tests_tests_snapshot_mangle-exports_single-export_input_1mf78g4ogdhgv._.js.map new file mode 100644 index 000000000000..bd58435855c8 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/0_9x_turbopack-tests_tests_snapshot_mangle-exports_single-export_input_1mf78g4ogdhgv._.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["turbopack:///[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/input/index.js","turbopack:///[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/input/lone.js"],"sourcesContent":["import { aLongFunctionNameNobodyWantsInTheBundle } from './lone'\n\nconsole.log(aLongFunctionNameNobodyWantsInTheBundle())\n","// The only export of this module. It is emitted under the fixed single-export key rather than a\n// hashed one, so that every single-export module in the graph produces the same `.f` byte sequence\n// and gzip can share it. Visible in the committed output below.\nexport function aLongFunctionNameNobodyWantsInTheBundle() {\n return 'lone'\n}\n"],"names":["console","log","aLongFunctionNameNobodyWantsInTheBundle"],"mappings":"6JAEAA,QAAQC,GAAG,CAAC,CAAA,EAFZ,AAEY,EAFZ,CAAA,CAAA,4HAEY,CAAA,AAAuC,qKCC5C,SAASC,EACd,MAAO,MACT"} \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/[turbopack]_runtime.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/[turbopack]_runtime.js new file mode 100644 index 000000000000..af1ff6b42072 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/[turbopack]_runtime.js @@ -0,0 +1,4 @@ +var RUNTIME_PUBLIC_PATH = "output/[turbopack]_runtime.js"; +var RELATIVE_ROOT_PATH = "../../../../../../.."; +var ASSET_PREFIX = "/"; +// Dummy runtime \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/[turbopack]_runtime.js.map b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/[turbopack]_runtime.js.map new file mode 100644 index 000000000000..c15d7ec00382 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/[turbopack]_runtime.js.map @@ -0,0 +1,5 @@ +{ + "version": 3, + "sources": [], + "sections": [] +} \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/index.entry.js b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/index.entry.js new file mode 100644 index 000000000000..62566e7d1530 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/index.entry.js @@ -0,0 +1,4 @@ +var R=require("./[turbopack]_runtime.js")("output/index.entry.js") +R.c("output/0_9x_turbopack-tests_tests_snapshot_mangle-exports_single-export_input_1mf78g4ogdhgv._.js") +R.m("[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/input/index.js [test] (ecmascript)") +module.exports=R.m("[project]/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/input/index.js [test] (ecmascript)").exports \ No newline at end of file diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/index.entry.js.map b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/index.entry.js.map new file mode 100644 index 000000000000..c15d7ec00382 --- /dev/null +++ b/turbopack/crates/turbopack-tests/tests/snapshot/mangle-exports/single-export/output/index.entry.js.map @@ -0,0 +1,5 @@ +{ + "version": 3, + "sources": [], + "sections": [] +} \ No newline at end of file diff --git a/turbopack/crates/turbopack-wasm/src/module_asset.rs b/turbopack/crates/turbopack-wasm/src/module_asset.rs index 41a502c7393d..68dd2562dba0 100644 --- a/turbopack/crates/turbopack-wasm/src/module_asset.rs +++ b/turbopack/crates/turbopack-wasm/src/module_asset.rs @@ -212,7 +212,9 @@ impl ChunkableModule for WebAssemblyModuleAsset { impl EcmascriptChunkPlaceable for WebAssemblyModuleAsset { #[turbo_tasks::function] fn get_exports(self: Vc) -> Vc { - self.loader().get_exports() + // This module hands out the *loader* module's exports as its own, so they must not carry + // a mangling decision — see `EcmascriptExports::borrowed`. + self.loader().get_exports().borrowed() } #[turbo_tasks::function] diff --git a/turbopack/crates/turbopack/src/module_options/mod.rs b/turbopack/crates/turbopack/src/module_options/mod.rs index c1d235ba4399..1d1663bdc9ac 100644 --- a/turbopack/crates/turbopack/src/module_options/mod.rs +++ b/turbopack/crates/turbopack/src/module_options/mod.rs @@ -247,6 +247,7 @@ impl ModuleOptions { inline_helpers, infer_module_side_effects, cjs_tree_shaking, + mangle_export_names, cjs_scope_hoisting, cross_module_constants, ref preset_env_config, @@ -343,6 +344,7 @@ impl ModuleOptions { inline_helpers, infer_module_side_effects, cjs_tree_shaking, + mangle_export_names, cjs_scope_hoisting, cross_module_constants, ..Default::default() diff --git a/turbopack/crates/turbopack/src/module_options/module_options_context.rs b/turbopack/crates/turbopack/src/module_options/module_options_context.rs index 9aaf21f455bc..488c4b4161de 100644 --- a/turbopack/crates/turbopack/src/module_options/module_options_context.rs +++ b/turbopack/crates/turbopack/src/module_options/module_options_context.rs @@ -284,6 +284,12 @@ pub struct EcmascriptOptionsContext { /// Whether to tree shake unused exports from static CommonJS modules. Defaults to false. pub cjs_tree_shaking: bool, + + /// Whether to shorten ("mangle") the export names a module exposes to other modules, to + /// reduce output size. Only affects the keys used to link modules together, never a name that + /// is observable from user code — modules whose export names can escape keep their original + /// names. Defaults to false. + pub mangle_export_names: bool, /// Whether to scope-hoist static CommonJS modules. Defaults to false. pub cjs_scope_hoisting: bool,