diff --git a/Cargo.lock b/Cargo.lock index 8acdcf5..1bde680 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1273,6 +1273,7 @@ dependencies = [ "swc_atoms", "swc_common", "swc_ecma_ast", + "swc_ecma_codegen", "swc_ecma_parser", "swc_ecma_transforms_base", "swc_ecma_transforms_testing", @@ -1584,6 +1585,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "swc_plugin_remove_side_effect" +version = "0.0.1" +dependencies = [ + "fxhash", + "serde", + "serde_json", + "swc_common", + "swc_core", + "testing", + "tracing", +] + [[package]] name = "swc_trace_macro" version = "2.0.0" diff --git a/Cargo.toml b/Cargo.toml index 430bca6..cddb329 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,11 @@ [workspace] +resolver = "2" members = [ "packages/remove-export", "packages/keep-platform", "packages/keep-export", - "packages/node-transform" + "packages/node-transform", + "packages/remove-side-effect" ] [workspace.dependencies] diff --git a/packages/remove-side-effect/.cargo/config.toml b/packages/remove-side-effect/.cargo/config.toml new file mode 100644 index 0000000..44dc7cd --- /dev/null +++ b/packages/remove-side-effect/.cargo/config.toml @@ -0,0 +1,4 @@ +# These command aliases are not final, may change +[alias] +# Alias to build actual plugin binary for the specified target. +prepublish = "build --target wasm32-wasi" diff --git a/packages/remove-side-effect/Cargo.toml b/packages/remove-side-effect/Cargo.toml new file mode 100644 index 0000000..e47b7ab --- /dev/null +++ b/packages/remove-side-effect/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "swc_plugin_remove_side_effect" +version = "0.0.1" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +serde = { workspace = true } +fxhash= { workspace = true } +tracing = { workspace = true, features = ["release_max_level_info"] } +swc_core = { workspace = true, features = [ + "ecma_plugin_transform", + "ecma_utils", + "ecma_visit", + "ecma_ast", + "common", + "ecma_codegen", + "ecma_parser", +]} +swc_common = { workspace = true, features = ["concurrent"] } +serde_json = { workspace = true, features = ["unbounded_depth"]} + +[dev-dependencies] +testing = { workspace = true } diff --git a/packages/remove-side-effect/package.json b/packages/remove-side-effect/package.json new file mode 100644 index 0000000..8d31bc6 --- /dev/null +++ b/packages/remove-side-effect/package.json @@ -0,0 +1,13 @@ +{ + "name": "@ice/swc-plugin-remove-side-effect", + "version": "0.0.1", + "license": "MIT", + "keywords": ["swc-plugin"], + "main": "swc_plugin_remove_side_effect.wasm", + "scripts": { + "prepublishOnly": "cargo prepublish --release && cp ../../target/wasm32-wasi/release/swc_plugin_remove_side_effect.wasm ." + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/remove-side-effect/src/lib.rs b/packages/remove-side-effect/src/lib.rs new file mode 100644 index 0000000..702f04d --- /dev/null +++ b/packages/remove-side-effect/src/lib.rs @@ -0,0 +1,250 @@ +use swc_core::ecma::{ + ast::*, + visit::{as_folder, FoldWith, VisitMut, VisitMutWith}, +}; +use swc_core::plugin::{plugin_transform, proxies::TransformPluginProgramMetadata}; + +#[derive(Default)] +pub struct TransformVisitor { + // Track React imports (e.g., import React from 'react') + react_imports: Vec, + // Track imported hooks (e.g., import { useEffect } from 'react') + imported_hooks: Vec, + // Stack of scopes for tracking variable declarations + scope_stack: Vec>, +} + +impl TransformVisitor { + pub fn new() -> Self { + Self { + react_imports: Vec::new(), + imported_hooks: Vec::new(), + scope_stack: vec![Vec::new()], + } + } + + fn target_hooks() -> Vec<&'static str> { + vec!["useEffect", "useLayoutEffect"] + } + + fn is_target_hook(name: &str) -> bool { + Self::target_hooks().contains(&name) + } + + fn enter_scope(&mut self) { + self.scope_stack.push(Vec::new()); + } + + fn exit_scope(&mut self) { + self.scope_stack.pop(); + } + + fn add_to_current_scope(&mut self, name: String) { + if let Some(scope) = self.scope_stack.last_mut() { + scope.push(name); + } + } + + fn is_local_variable(&self, name: &str) -> bool { + for scope in self.scope_stack.iter().rev() { + if scope.contains(&name.to_string()) { + return true; + } + } + false + } + + fn is_removable_effect(&self, expr: &Expr) -> bool { + match expr { + Expr::Ident(ident) => { + let name = ident.sym.to_string(); + if Self::is_target_hook(&name) { + if self.is_local_variable(&name) { + return false; + } + return self.imported_hooks.contains(&name); + } + } + Expr::Member(member) => { + if let Expr::Ident(obj) = &*member.obj { + if let Some(prop) = &member.prop.as_ident() { + return self.react_imports.contains(&obj.sym.to_string()) + && Self::is_target_hook(&prop.sym.to_string()); + } + } + } + _ => {} + } + false + } +} + +impl VisitMut for TransformVisitor { + fn visit_mut_stmts(&mut self, stmts: &mut Vec) { + self.enter_scope(); + + // First, process all variable declarations to build the scope + for stmt in stmts.iter_mut() { + if let Stmt::Decl(Decl::Var(var_decl)) = stmt { + for decl in &var_decl.decls { + if let Pat::Ident(ident) = &decl.name { + self.add_to_current_scope(ident.id.sym.to_string()); + } + } + } + } + + // Process statements and remove React hooks + stmts.retain(|stmt| { + if let Stmt::Expr(expr_stmt) = stmt { + if let Expr::Call(call_expr) = &*expr_stmt.expr { + if let Callee::Expr(callee) = &call_expr.callee { + if let Expr::Ident(ident) = &**callee { + let name = ident.sym.to_string(); + // If it's a local variable, keep the call + if self.is_local_variable(&name) { + return true; + } + // If it's an imported hook, remove the call + if self.imported_hooks.contains(&name) { + return false; + } + } + return !self.is_removable_effect(callee); + } + } + } + true + }); + + // Process child nodes + for stmt in stmts.iter_mut() { + stmt.visit_mut_children_with(self); + } + + self.exit_scope(); + } + + fn visit_mut_block_stmt(&mut self, block: &mut BlockStmt) { + self.enter_scope(); + // Process all statements in the block + self.visit_mut_stmts(&mut block.stmts); + self.exit_scope(); + } + + fn visit_mut_function(&mut self, func: &mut Function) { + self.enter_scope(); + + // Add function parameters to scope + for param in &func.params { + if let Pat::Ident(ident) = ¶m.pat { + self.add_to_current_scope(ident.id.sym.to_string()); + } + } + + // Process function body + if let Some(body) = &mut func.body { + self.visit_mut_stmts(&mut body.stmts); + } + + self.exit_scope(); + } + + fn visit_mut_arrow_expr(&mut self, arrow: &mut ArrowExpr) { + self.enter_scope(); + + // Add arrow function parameters to scope + for param in &arrow.params { + if let Pat::Ident(ident) = param { + self.add_to_current_scope(ident.sym.to_string()); + } + } + + // Process arrow function body + match &mut *arrow.body { + BlockStmtOrExpr::BlockStmt(block) => { + self.visit_mut_block_stmt(block); + } + BlockStmtOrExpr::Expr(expr) => { + expr.visit_mut_with(self); + } + } + + self.exit_scope(); + } + + fn visit_mut_var_decl(&mut self, var_decl: &mut VarDecl) { + for decl in &var_decl.decls { + if let Pat::Ident(ident) = &decl.name { + self.add_to_current_scope(ident.id.sym.to_string()); + } + } + var_decl.visit_mut_children_with(self); + } + + fn visit_mut_module(&mut self, module: &mut Module) { + for item in &module.body { + if let ModuleItem::ModuleDecl(ModuleDecl::Import(import)) = item { + if import.src.value.to_string() == "react" { + for spec in &import.specifiers { + match spec { + ImportSpecifier::Named(named) => { + let original_name = match &named.imported { + Some(imported) => match imported { + // eg: import { useEffect as myEffect } from "react"; + ModuleExportName::Ident(ident) => ident.sym.to_string(), + // eg: import { 'use-effect' as myEffect } from 'react'; + ModuleExportName::Str(str) => str.value.to_string(), + }, + None => named.local.sym.to_string(), + }; + + if Self::is_target_hook(&original_name) { + self.imported_hooks.push(named.local.sym.to_string()); + } + } + ImportSpecifier::Default(default_import) => { + self.react_imports + .push(default_import.local.sym.to_string()); + } + ImportSpecifier::Namespace(namespace) => { + self.react_imports.push(namespace.local.sym.to_string()); + } + } + } + } + } + } + + module.visit_mut_children_with(self); + } + + fn visit_mut_try_stmt(&mut self, try_stmt: &mut TryStmt) { + self.enter_scope(); + try_stmt.block.visit_mut_children_with(self); + self.exit_scope(); + + // catch + if let Some(catch) = &mut try_stmt.handler { + self.enter_scope(); + // add catch params to context + if let Some(Pat::Ident(ident)) = &catch.param { + self.add_to_current_scope(ident.sym.to_string()); + } + catch.body.visit_mut_children_with(self); + self.exit_scope(); + } + + // finally + if let Some(finally) = &mut try_stmt.finalizer { + self.enter_scope(); + finally.visit_mut_children_with(self); + self.exit_scope(); + } + } +} + +#[plugin_transform] +pub fn process_transform(program: Program, _metadata: TransformPluginProgramMetadata) -> Program { + program.fold_with(&mut as_folder(TransformVisitor::new())) +} diff --git a/packages/remove-side-effect/tests/fixtrue.rs b/packages/remove-side-effect/tests/fixtrue.rs new file mode 100644 index 0000000..327f605 --- /dev/null +++ b/packages/remove-side-effect/tests/fixtrue.rs @@ -0,0 +1,22 @@ +use std::path::PathBuf; +use swc_core::ecma::parser::{Syntax, EsSyntax}; +use swc_core::ecma::transforms::testing::test_fixture; +use swc_core::ecma::visit::as_folder; +use swc_plugin_remove_side_effect::TransformVisitor; +use testing::fixture; + +#[fixture("tests/fixture/**/input.js")] +fn fixture_test(input: PathBuf) { + let output = input.parent().unwrap().join("output.js"); + test_fixture( + Syntax::Es(EsSyntax { + jsx: true, + decorators: true, + ..Default::default() + }), + &|_| as_folder(TransformVisitor::new()), + &input, + &output, + Default::default(), + ); +} diff --git a/packages/remove-side-effect/tests/fixture/base/input.js b/packages/remove-side-effect/tests/fixture/base/input.js new file mode 100644 index 0000000..e508e22 --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/base/input.js @@ -0,0 +1,29 @@ +import { useEffect, useLayoutEffect } from "react"; + +const Component = () => { + useEffect(() => { + console.log("Hello"); + }, []); + + useLayoutEffect(() => { + console.log("Hello Layout"); + }, []); + + return
Hello
+} + +function Component2() { + useEffect(() => { + console.log("Hello"); + }, []); + + useLayoutEffect(() => { + console.log("Hello Layout"); + }, []); + + return
Hello
+} + +export { Component2 }; + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/base/output.js b/packages/remove-side-effect/tests/fixture/base/output.js new file mode 100644 index 0000000..b6d7af2 --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/base/output.js @@ -0,0 +1,15 @@ +import { useEffect, useLayoutEffect } from "react"; + +const Component = () => { + + return
Hello
+} + +function Component2() { + + return
Hello
+} + +export { Component2 }; + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/default-import-react/input.js b/packages/remove-side-effect/tests/fixture/default-import-react/input.js new file mode 100644 index 0000000..fbd66a7 --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/default-import-react/input.js @@ -0,0 +1,11 @@ +import ReactA, { useState } from 'react' + +const Component = () => { + ReactA.useEffect(() => { + console.log("Hello"); + }, []); + + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/default-import-react/output.js b/packages/remove-side-effect/tests/fixture/default-import-react/output.js new file mode 100644 index 0000000..dc8c529 --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/default-import-react/output.js @@ -0,0 +1,8 @@ +import ReactA, { useState } from 'react' + +const Component = () => { + + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/hooks/input.js b/packages/remove-side-effect/tests/fixture/hooks/input.js new file mode 100644 index 0000000..dc97cc7 --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/hooks/input.js @@ -0,0 +1,23 @@ +import { useEffect, useLayoutEffect } from "react"; + +function useCustomHook() { + useEffect(() => { + console.log("Custom Hook Effect"); + }, []); + + useLayoutEffect(() => { + console.log("Hello Layout"); + }, []); + + { + const useEffect = () => {} + useEffect() + } +} + +const Component = () => { + useCustomHook(); + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/hooks/output.js b/packages/remove-side-effect/tests/fixture/hooks/output.js new file mode 100644 index 0000000..5285dfc --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/hooks/output.js @@ -0,0 +1,16 @@ +import { useEffect, useLayoutEffect } from "react"; + +function useCustomHook() { + + { + const useEffect = () => {} + useEffect() + } +} + +const Component = () => { + useCustomHook(); + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/mix-import-react/input.js b/packages/remove-side-effect/tests/fixture/mix-import-react/input.js new file mode 100644 index 0000000..2f3286b --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/mix-import-react/input.js @@ -0,0 +1,20 @@ +import React, { useLayoutEffect } from 'react' +import { useEffect } from 'react' + +const Component = () => { + React.useEffect(() => { + console.log("React.useEffect"); + }, []); + + useEffect(() => { + console.log("useEffect"); + }, []); + + useLayoutEffect(() => { + console.log("useLayoutEffect"); + }, []); + + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/mix-import-react/output.js b/packages/remove-side-effect/tests/fixture/mix-import-react/output.js new file mode 100644 index 0000000..8157df4 --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/mix-import-react/output.js @@ -0,0 +1,9 @@ +import React, { useLayoutEffect } from 'react' +import { useEffect } from 'react' + +const Component = () => { + + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/mutli-import/input.js b/packages/remove-side-effect/tests/fixture/mutli-import/input.js new file mode 100644 index 0000000..f6ca81e --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/mutli-import/input.js @@ -0,0 +1,11 @@ +import { useEffect, useCallback } from "react"; + +const Component = () => { + useEffect(() => { + console.log("Hello"); + }, []); + + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/mutli-import/output.js b/packages/remove-side-effect/tests/fixture/mutli-import/output.js new file mode 100644 index 0000000..211bc72 --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/mutli-import/output.js @@ -0,0 +1,8 @@ +import { useEffect, useCallback } from "react"; + +const Component = () => { + + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/namespace-import-react/input.js b/packages/remove-side-effect/tests/fixture/namespace-import-react/input.js new file mode 100644 index 0000000..b0fad30 --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/namespace-import-react/input.js @@ -0,0 +1,11 @@ +import * as ReactA from 'react' + +const Component = () => { + ReactA.useEffect(() => { + console.log("Hello"); + }, []); + + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/namespace-import-react/output.js b/packages/remove-side-effect/tests/fixture/namespace-import-react/output.js new file mode 100644 index 0000000..7ffb942 --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/namespace-import-react/output.js @@ -0,0 +1,8 @@ +import * as ReactA from 'react' + +const Component = () => { + + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/nest/input.js b/packages/remove-side-effect/tests/fixture/nest/input.js new file mode 100644 index 0000000..218c56d --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/nest/input.js @@ -0,0 +1,36 @@ +import { useEffect } from "react"; + +const Component = () => { + useEffect(() => { + console.log("Hello"); + + const useEffect = () => {} + useEffect() + }, []); + + { + const useEffect = () => {} + useEffect() + } + + try { + const useEffect = () => {} + useEffect() + } catch (e) { + // React useEffect + useEffect(() => {}) + } + + const B = () => { + const useEffect = () => { + console.log('another UseEffect'); + }; + useEffect(); + } + + B(); + + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/nest/output.js b/packages/remove-side-effect/tests/fixture/nest/output.js new file mode 100644 index 0000000..8ffe5ca --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/nest/output.js @@ -0,0 +1,27 @@ +import { useEffect } from "react"; + +const Component = () => { + + { + const useEffect = () => {} + useEffect() + } + + try { + const useEffect = () => {} + useEffect() + } catch (e) {} + + const B = () => { + const useEffect = () => { + console.log('another UseEffect'); + }; + useEffect(); + } + + B(); + + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/redeclar/input.js b/packages/remove-side-effect/tests/fixture/redeclar/input.js new file mode 100644 index 0000000..a43bfca --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/redeclar/input.js @@ -0,0 +1,20 @@ +import { useEffect, useLayoutEffect } from "react"; + +const Component = () => { + // can't find useEffect, because the Temporal Dead Zone, TDZ, but we should not to remove it + // because it's a local variable, not form react + useEffect(() => { + console.log("Hello"); + }, []); + + useLayoutEffect(() => { + console.log("Hello Layout"); + }, []); + + const useEffect = () => {} + useEffect() + + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/redeclar/output.js b/packages/remove-side-effect/tests/fixture/redeclar/output.js new file mode 100644 index 0000000..c48e400 --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/redeclar/output.js @@ -0,0 +1,17 @@ +import { useEffect, useLayoutEffect } from "react"; + +const Component = () => { + // can't find useEffect, because the Temporal Dead Zone, TDZ, but we should not to remove it + // because it's a local variable, not form react + useEffect(() => { + console.log("Hello"); + }, []); + + + const useEffect = () => {} + useEffect() + + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/rename-import-react/input.js b/packages/remove-side-effect/tests/fixture/rename-import-react/input.js new file mode 100644 index 0000000..a69d7bc --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/rename-import-react/input.js @@ -0,0 +1,21 @@ +import { useEffect as myEffect, useLayoutEffect as myLayout } from "react"; +import { 'useEffect' as myEffect2 } from 'react'; + + +const Component = () => { + myEffect2(() => { + console.log("Hello"); + }, []); + + myEffect(() => { + console.log("Hello"); + }, []); + + myLayout(() => { + console.log("Layout"); + }, []); + + return
Hello
+} + +export default Component; \ No newline at end of file diff --git a/packages/remove-side-effect/tests/fixture/rename-import-react/output.js b/packages/remove-side-effect/tests/fixture/rename-import-react/output.js new file mode 100644 index 0000000..8e1816a --- /dev/null +++ b/packages/remove-side-effect/tests/fixture/rename-import-react/output.js @@ -0,0 +1,8 @@ +import { useEffect as myEffect, useLayoutEffect as myLayout } from "react"; +import { 'useEffect' as myEffect2 } from 'react'; +const Component = () => { + + return
Hello
+} + +export default Component; \ No newline at end of file