vercel/next.js · #100477

Turbopack: keep IIFEs with a PURE-annotated callee in production builds

sokra · merged Nov 5, 20269 files · 201 + / 24 −
.prettierignore2 + / 0 −
@@ -74,6 +74,8 @@ test/development/mcp-server/fixtures/compilation-errors-app/app/syntax-error/pag /turbopack/crates/turbopack-tests/tests/snapshot/css/css-parse-error/input/style.css /test/development/app-dir/css-parse-error-recovery/app/css-error/style.css /turbopack/crates/turbopack-tests/tests/snapshot/import-meta/cjs/input/mod.cjs+# prettier removes the parentheses that scope the PURE annotation to the inner call+/turbopack/crates/turbopack-tests/tests/execution/turbopack/minification/pure-annotated-callee*/input/index.js /turbopack/crates/turbopack-tests/tests/snapshot/source_maps/* /turbopack/crates/turbopack-tests/tests/**/output* /turbopack/crates/turbopack-tests/tests/**/static
turbopack/crates/turbopack-ecmascript/src/lib.rs16 + / 2 −
@@ -130,7 +130,7 @@ use crate::{         exports::compute_ecmascript_module_exports,     },     side_effect_optimization::reference::EcmascriptModulePartReference,-    swc_comments::{CowComments, ImmutableComments},+    swc_comments::{CowComments, ImmutableComments, ReadOnlyComments},     transform::{remove_directives, remove_shebang}, }; pub use crate::{@@ -2161,7 +2161,21 @@ async fn process_parse_result(                         ),                     );                 }-                program.visit_mut_with(&mut swc_core::ecma::transforms::base::fixer::fixer(None));+                // The fixer needs to see the comments: without them it drops the parentheses in+                // `(/* @__PURE__ */ f())()`, which moves the PURE annotation onto the outer call+                // when the code is printed (and re-parsed by the minifier), so the whole call+                // would be removed as side-effect free.+                {+                    let parsed_comments: &ImmutableComments = match &comments {+                        Either::Left(c) => c,+                        Either::Right(c) => c,+                    };+                    let stores: [&dyn Comments; 2] = [parsed_comments, &extra_comments];+                    let fixer_comments = ReadOnlyComments::new(&stores);+                    program.visit_mut_with(&mut swc_core::ecma::transforms::base::fixer::fixer(+                        Some(&fixer_comments),+                    ));+                }                  // we need to remove any shebang before bundling as it's only valid as the first                 // line in a js file (not in a chunk item wrapped in the runtime)
turbopack/crates/turbopack-ecmascript/src/swc_comments.rs122 + / 22 −
@@ -213,28 +213,7 @@ impl Comments for ImmutableComments {     }      fn has_flag(&self, pos: BytePos, flag: &str) -> bool {-        self.with_leading(pos, |cmts| {-            for c in cmts {-                if c.kind == CommentKind::Block {-                    for line in c.text.lines() {-                        // jsdoc-                        let line = line.trim_start_matches(['*', ' ']);-                        let line = line.trim();--                        //-                        if line.len() == (flag.len() + 5)-                            && (line.starts_with("#__") || line.starts_with("@__"))-                            && line.ends_with("__")-                            && flag == &line[3..line.len() - 2]-                        {-                            return true;-                        }-                    }-                }-            }--            false-        })+        self.with_leading(pos, |cmts| comments_have_flag(cmts, flag))     }      fn with_leading<F, Ret>(&self, pos: BytePos, f: F) -> Ret@@ -266,6 +245,127 @@ impl Comments for ImmutableComments {     } } +/// Checks whether `comments` contain a `@__FLAG__` / `#__FLAG__` block comment, using the same+/// matching rules as SWC's default [Comments::has_flag], but without mutating anything.+fn comments_have_flag(comments: &[Comment], flag: &str) -> bool {+    for c in comments {+        if c.kind == CommentKind::Block {+            for line in c.text.lines() {+                // jsdoc+                let line = line.trim_start_matches(['*', ' ']);+                let line = line.trim();++                //+                if line.len() == (flag.len() + 5)+                    && (line.starts_with("#__") || line.starts_with("@__"))+                    && line.ends_with("__")+                    && flag == &line[3..line.len() - 2]+                {+                    return true;+                }+            }+        }+    }++    false+}++/// A read-only [Comments] view over several comment stores.+///+/// Some SWC transforms (e.g. the fixer) need to *read* comments to make correct decisions: the+/// fixer keeps the parentheses around a `/* @__PURE__ */`-annotated callee, which would otherwise+/// move the annotation onto the outer call. The comment stores at that point must not be changed+/// ([ImmutableComments] can't be), so this adapter:+///+/// - answers `has_*` / `get_*` / `has_flag` by looking at every store and concatenating the results+///   (`has_flag` is overridden because SWC's default implementation uses `take_leading` followed by+///   `add_trailing_comments`),+/// - ignores `move_*`: the fixer calls them for every pair of parentheses it removes, to move the+///   comments from the paren span to the inner expression. Ignoring them keeps the comments at+///   their original positions, which is what happened before the fixer saw any comments,+/// - panics on every other mutation (`add_*`, `take_*`, `add_pure_comment`) to make sure it is+///   never used where comments actually need to be changed.+pub struct ReadOnlyComments<'a> {+    stores: &'a [&'a dyn Comments],+}++impl<'a> ReadOnlyComments<'a> {+    pub fn new(stores: &'a [&'a dyn Comments]) -> Self {+        Self { stores }+    }++    fn collect(&self, f: impl Fn(&dyn Comments) -> Option<Vec<Comment>>) -> Option<Vec<Comment>> {+        let mut result: Option<Vec<Comment>> = None;+        for store in self.stores {+            if let Some(c) = f(*store) {+                result.get_or_insert_with(Vec::new).extend(c);+            }+        }+        result+    }+}++impl Comments for ReadOnlyComments<'_> {+    fn add_leading(&self, _pos: BytePos, _cmt: Comment) {+        panic!("ReadOnlyComments can't be mutated")+    }++    fn add_leading_comments(&self, _pos: BytePos, _comments: Vec<Comment>) {+        panic!("ReadOnlyComments can't be mutated")+    }++    fn has_leading(&self, pos: BytePos) -> bool {+        self.stores.iter().any(|s| s.has_leading(pos))+    }++    fn move_leading(&self, _from: BytePos, _to: BytePos) {+        // Intentionally ignored, see the type's documentation.+    }++    fn take_leading(&self, _pos: BytePos) -> Option<Vec<Comment>> {+        panic!("ReadOnlyComments can't be mutated")+    }++    fn get_leading(&self, pos: BytePos) -> Option<Vec<Comment>> {+        self.collect(|s| s.get_leading(pos))+    }++    fn add_trailing(&self, _pos: BytePos, _cmt: Comment) {+        panic!("ReadOnlyComments can't be mutated")+    }++    fn add_trailing_comments(&self, _pos: BytePos, _comments: Vec<Comment>) {+        panic!("ReadOnlyComments can't be mutated")+    }++    fn has_trailing(&self, pos: BytePos) -> bool {+        self.stores.iter().any(|s| s.has_trailing(pos))+    }++    fn move_trailing(&self, _from: BytePos, _to: BytePos) {+        // Intentionally ignored, see the type's documentation.+    }++    fn take_trailing(&self, _pos: BytePos) -> Option<Vec<Comment>> {+        panic!("ReadOnlyComments can't be mutated")+    }++    fn get_trailing(&self, pos: BytePos) -> Option<Vec<Comment>> {+        self.collect(|s| s.get_trailing(pos))+    }++    fn add_pure_comment(&self, _pos: BytePos) {+        panic!("ReadOnlyComments can't be mutated")+    }++    fn has_flag(&self, pos: BytePos, flag: &str) -> bool {+        self.stores.iter().any(|s| {+            s.get_leading(pos)+                .is_some_and(|cmts| comments_have_flag(&cmts, flag))+        })+    }+}+ pub struct CowComments<'a> {     leading: RefCell<FxHashMap<BytePos, Cow<'a, [Comment]>>>,     trailing: RefCell<FxHashMap<BytePos, Cow<'a, [Comment]>>>,
turbopack/crates/turbopack-tests/tests/execution/turbopack/minification/pure-annotated-callee-no-scope-hoisting/input/index.jsadded22 + / 0 −
@@ -0,0 +1,22 @@+import { __name } from './name.js'++const calls = []++function make() {+  return {+    run() {+      calls.push('member')+    },+  }+}++// The PURE annotations below apply only to the inner, parenthesized calls. The+// outer calls have side effects and must not be removed by the minifier.+;(/* @__PURE__ */ __name(function annotated() {+  calls.push('iife')+}, 'annotated'))()+;(/* @__PURE__ */ make()).run()++it('should keep calls whose callee is a PURE-annotated call', () => {+  expect(calls).toEqual(['iife', 'member'])+})
turbopack/crates/turbopack-tests/tests/execution/turbopack/minification/pure-annotated-callee-no-scope-hoisting/input/name.jsadded5 + / 0 −
@@ -0,0 +1,5 @@+// esbuild's `keepNames` helpers.+var __defProp = Object.defineProperty++export var __name = (target, value) =>+  __defProp(target, 'name', { value, configurable: true })
turbopack/crates/turbopack-tests/tests/execution/turbopack/minification/pure-annotated-callee-no-scope-hoisting/options.jsonadded4 + / 0 −
@@ -0,0 +1,4 @@+{+  "minify": true,+  "scopeHoisting": false+}
turbopack/crates/turbopack-tests/tests/execution/turbopack/minification/pure-annotated-callee/input/index.jsadded22 + / 0 −
@@ -0,0 +1,22 @@+import { __name } from './name.js'++const calls = []++function make() {+  return {+    run() {+      calls.push('member')+    },+  }+}++// The PURE annotations below apply only to the inner, parenthesized calls. The+// outer calls have side effects and must not be removed by the minifier.+;(/* @__PURE__ */ __name(function annotated() {+  calls.push('iife')+}, 'annotated'))()+;(/* @__PURE__ */ make()).run()++it('should keep calls whose callee is a PURE-annotated call', () => {+  expect(calls).toEqual(['iife', 'member'])+})
turbopack/crates/turbopack-tests/tests/execution/turbopack/minification/pure-annotated-callee/input/name.jsadded5 + / 0 −
@@ -0,0 +1,5 @@+// esbuild's `keepNames` helpers.+var __defProp = Object.defineProperty++export var __name = (target, value) =>+  __defProp(target, 'name', { value, configurable: true })
turbopack/crates/turbopack-tests/tests/execution/turbopack/minification/pure-annotated-callee/options.jsonadded3 + / 0 −
@@ -0,0 +1,3 @@+{+  "minify": true+}