diff --git a/.changeset/feat-react-transform-remove-call.md b/.changeset/feat-react-transform-remove-call.md new file mode 100644 index 0000000000..373b083f78 --- /dev/null +++ b/.changeset/feat-react-transform-remove-call.md @@ -0,0 +1,9 @@ +--- +"@lynx-js/react": minor +"@lynx-js/react-webpack-plugin": minor +"@lynx-js/react-rsbuild-plugin": minor +--- + +Add `removeCall` for shake function calls. Its initial default value matches the hooks that were previously in `removeCallParams`, and `removeCallParams` now defaults to empty. + +`removeCall` removes matched runtime hook calls entirely, replacing them with `undefined` in expression positions and dropping them in statement positions. `removeCallParams` keeps the existing behavior of preserving the call while stripping its arguments. diff --git a/packages/react/transform/crates/swc_plugin_shake/lib.rs b/packages/react/transform/crates/swc_plugin_shake/lib.rs index bde855a736..e91cc359a5 100644 --- a/packages/react/transform/crates/swc_plugin_shake/lib.rs +++ b/packages/react/transform/crates/swc_plugin_shake/lib.rs @@ -1,6 +1,7 @@ use serde::Deserialize; use std::collections::HashMap; use swc_core::{ + common::DUMMY_SP, ecma::ast::*, ecma::visit::{VisitMut, VisitMutWith, VisitWith}, }; @@ -63,6 +64,31 @@ pub struct ShakeVisitorConfig { /// @public pub retain_prop: Vec, + /// Function names whose calls should be replaced with `undefined` during transformation + /// + /// @example + /// ```js + /// import { defineConfig } from '@lynx-js/rspeedy' + /// import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin' + /// + /// export default defineConfig({ + /// plugins: [ + /// pluginReactLynx({ + /// shake: { + /// removeCall: ['useMyCustomEffect'] + /// } + /// }) + /// ] + /// }) + /// ``` + /// + /// @remarks + /// Default value: `['useEffect', 'useLayoutEffect', '__runInJS', 'useLynxGlobalEventListener', 'useImperativeHandle']` + /// The provided values will be merged with the default values instead of replacing them. + /// + /// @public + pub remove_call: Vec, + /// Function names whose parameters should be removed during transformation /// /// @example @@ -82,7 +108,7 @@ pub struct ShakeVisitorConfig { /// ``` /// /// @remarks - /// Default value: `['useEffect', 'useLayoutEffect', '__runInJS', 'useLynxGlobalEventListener', 'useImperativeHandle']` + /// Default value: `[]` /// The provided values will be merged with the default values instead of replacing them. /// /// @public @@ -102,7 +128,7 @@ impl Default for ShakeVisitorConfig { "contextType", "defaultProps", ]; - let default_remove_call_params = [ + let default_remove_call = [ "useEffect", "useLayoutEffect", "__runInJS", @@ -112,10 +138,8 @@ impl Default for ShakeVisitorConfig { ShakeVisitorConfig { pkg_name: default_pkg_name.iter().map(|x| x.to_string()).collect(), retain_prop: default_retain_prop.iter().map(|x| x.to_string()).collect(), - remove_call_params: default_remove_call_params - .iter() - .map(|x| x.to_string()) - .collect(), + remove_call: default_remove_call.iter().map(|x| x.to_string()).collect(), + remove_call_params: Vec::new(), } } } @@ -138,6 +162,19 @@ impl ShakeVisitor { import_ids: Vec::new(), } } + + /// Returns true when the call targets a configured runtime import. + /// + /// This shared check is used by both: + /// - `remove_call`, where the whole call expression is removed/replaced + /// - `remove_call_params`, where only the call arguments are cleared + fn should_remove_call(&self, n: &CallExpr, target_calls: &[String]) -> bool { + if let Some(fn_name) = n.callee.as_expr().and_then(|s| s.as_ident()) { + self.import_ids.contains(&fn_name.to_id()) && target_calls.contains(&fn_name.sym.to_string()) + } else { + false + } + } } impl Default for ShakeVisitor { @@ -147,6 +184,28 @@ impl Default for ShakeVisitor { } impl VisitMut for ShakeVisitor { + fn visit_mut_stmt(&mut self, n: &mut Stmt) { + if let Stmt::Expr(expr_stmt) = n { + if let Expr::Call(call_expr) = &*expr_stmt.expr { + if self.should_remove_call(call_expr, &self.opts.remove_call) { + *n = Stmt::Empty(EmptyStmt { span: DUMMY_SP }); + return; + } + } + } + n.visit_mut_children_with(self); + } + + fn visit_mut_expr(&mut self, n: &mut Expr) { + if let Expr::Call(call_expr) = n { + if self.should_remove_call(call_expr, &self.opts.remove_call) { + *n = Expr::Ident(Ident::new("undefined".into(), DUMMY_SP, Default::default())); + return; + } + } + n.visit_mut_children_with(self); + } + /** * labeling import stmt */ @@ -176,15 +235,8 @@ impl VisitMut for ShakeVisitor { * labeling function call */ fn visit_mut_call_expr(&mut self, n: &mut CallExpr) { - if let Some(fn_name) = n.callee.as_expr().and_then(|s| s.as_ident()) { - if self.import_ids.contains(&fn_name.to_id()) - && self - .opts - .remove_call_params - .contains(&fn_name.sym.to_string()) - { - n.args.clear(); - } + if self.should_remove_call(n, &self.opts.remove_call_params) { + n.args.clear(); } n.visit_mut_children_with(self); } @@ -302,7 +354,7 @@ mod tests { ecma::{transforms::base::resolver, visit::visit_mut_pass}, }; - use crate::ShakeVisitor; + use crate::{ShakeVisitor, ShakeVisitorConfig}; test!( module, @@ -322,6 +374,68 @@ mod tests { "# ); + test!( + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |_| ( + resolver(Mark::new(), Mark::new(), true), + visit_mut_pass(ShakeVisitor::new(Default::default())), + ), + should_remove_use_effect_call, + r#" + import { useEffect } from "@lynx-js/react-runtime"; + const myUseEffect = useEffect; + export function A () { + useEffect(()=>{ + console.log("remove useEffect") + }) + myUseEffect(()=>{ + console.log("remove myUseEffect") + }) + } + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |_| ( + resolver(Mark::new(), Mark::new(), true), + visit_mut_pass(ShakeVisitor::new(Default::default())), + ), + should_not_remove_call_in_scope_id, + r#" + import { useEffect } from '@lynx-js/react-runtime' + { + const useEffect = () => {}; + useEffect(() => {}); + } + useEffect(() => {}); + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |_| ( + resolver(Mark::new(), Mark::new(), true), + visit_mut_pass(ShakeVisitor::new(Default::default())), + ), + should_replace_use_effect_call_with_undefined, + r#" + import { useEffect } from '@lynx-js/react-runtime' + const a = useEffect(() => {}); + "# + ); + test!( module, Syntax::Es(EsSyntax { @@ -484,13 +598,21 @@ mod tests { test!( Default::default(), - |_| visit_mut_pass(ShakeVisitor::default()), + |_| visit_mut_pass(ShakeVisitor::new(ShakeVisitorConfig { + remove_call: Vec::new(), + remove_call_params: vec!["useEffect".to_string()], + ..Default::default() + })), should_remove_use_effect_param, r#" import { useEffect } from "@lynx-js/react-runtime"; + const myUseEffect = useEffect; export function A () { useEffect(()=>{ - console.log("remove") + console.log("remove useEffect") + }) + myUseEffect(()=>{ + console.log("remove myUseEffect") }) } "# @@ -504,7 +626,11 @@ mod tests { }), |_| ( resolver(Mark::new(), Mark::new(), true), - visit_mut_pass(ShakeVisitor::new(Default::default())), + visit_mut_pass(ShakeVisitor::new(ShakeVisitorConfig { + remove_call_params: vec!["useEffect".to_string()], + remove_call: Vec::new(), + ..Default::default() + })), ), should_not_remove_in_scope_id, r#" diff --git a/packages/react/transform/crates/swc_plugin_shake/napi.rs b/packages/react/transform/crates/swc_plugin_shake/napi.rs index 213d206be1..85738b3014 100644 --- a/packages/react/transform/crates/swc_plugin_shake/napi.rs +++ b/packages/react/transform/crates/swc_plugin_shake/napi.rs @@ -57,6 +57,31 @@ pub struct ShakeVisitorConfig { /// @public pub retain_prop: Vec, + /// Function names whose calls should be replaced with `undefined` during transformation + /// + /// @example + /// ```js + /// import { defineConfig } from '@lynx-js/rspeedy' + /// import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin' + /// + /// export default defineConfig({ + /// plugins: [ + /// pluginReactLynx({ + /// shake: { + /// removeCall: ['useMyCustomEffect'] + /// } + /// }) + /// ] + /// }) + /// ``` + /// + /// @remarks + /// Default value: `['useEffect', 'useLayoutEffect', '__runInJS', 'useLynxGlobalEventListener', 'useImperativeHandle']` + /// The provided values will be merged with the default values instead of replacing them. + /// + /// @public + pub remove_call: Vec, + /// Function names whose parameters should be removed during transformation /// /// @example @@ -76,7 +101,7 @@ pub struct ShakeVisitorConfig { /// ``` /// /// @remarks - /// Default value: `['useEffect', 'useLayoutEffect', '__runInJS', 'useLynxGlobalEventListener', 'useImperativeHandle']` + /// Default value: `[]` /// The provided values will be merged with the default values instead of replacing them. /// /// @public @@ -96,7 +121,7 @@ impl Default for ShakeVisitorConfig { "contextType", "defaultProps", ]; - let default_remove_call_params = [ + let default_remove_call = [ "useEffect", "useLayoutEffect", "__runInJS", @@ -106,10 +131,8 @@ impl Default for ShakeVisitorConfig { ShakeVisitorConfig { pkg_name: default_pkg_name.iter().map(|x| x.to_string()).collect(), retain_prop: default_retain_prop.iter().map(|x| x.to_string()).collect(), - remove_call_params: default_remove_call_params - .iter() - .map(|x| x.to_string()) - .collect(), + remove_call: default_remove_call.iter().map(|x| x.to_string()).collect(), + remove_call_params: Vec::new(), } } } @@ -119,6 +142,7 @@ impl From for CoreConfig { CoreConfig { pkg_name: val.pkg_name, retain_prop: val.retain_prop, + remove_call: val.remove_call, remove_call_params: val.remove_call_params, } } @@ -129,6 +153,7 @@ impl From for ShakeVisitorConfig { ShakeVisitorConfig { pkg_name: val.pkg_name, retain_prop: val.retain_prop, + remove_call: val.remove_call, remove_call_params: val.remove_call_params, } } @@ -152,6 +177,14 @@ impl Default for ShakeVisitor { } impl VisitMut for ShakeVisitor { + fn visit_mut_stmt(&mut self, n: &mut Stmt) { + self.inner.visit_mut_stmt(n); + } + + fn visit_mut_expr(&mut self, n: &mut Expr) { + self.inner.visit_mut_expr(n); + } + fn visit_mut_import_decl(&mut self, n: &mut ImportDecl) { self.inner.visit_mut_import_decl(n); } diff --git a/packages/react/transform/crates/swc_plugin_shake/tests/__swc_snapshots__/lib.rs/should_not_remove_call_in_scope_id.js b/packages/react/transform/crates/swc_plugin_shake/tests/__swc_snapshots__/lib.rs/should_not_remove_call_in_scope_id.js new file mode 100644 index 0000000000..be67387166 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_shake/tests/__swc_snapshots__/lib.rs/should_not_remove_call_in_scope_id.js @@ -0,0 +1,5 @@ +import { useEffect } from '@lynx-js/react-runtime'; +{ + const useEffect = ()=>{}; + useEffect(()=>{}); +}; diff --git a/packages/react/transform/crates/swc_plugin_shake/tests/__swc_snapshots__/lib.rs/should_remove_use_effect_call.js b/packages/react/transform/crates/swc_plugin_shake/tests/__swc_snapshots__/lib.rs/should_remove_use_effect_call.js new file mode 100644 index 0000000000..d7e763f62c --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_shake/tests/__swc_snapshots__/lib.rs/should_remove_use_effect_call.js @@ -0,0 +1,8 @@ +import { useEffect } from "@lynx-js/react-runtime"; +const myUseEffect = useEffect; +export function A() { + ; + myUseEffect(()=>{ + console.log("remove myUseEffect"); + }); +} diff --git a/packages/react/transform/crates/swc_plugin_shake/tests/__swc_snapshots__/lib.rs/should_remove_use_effect_param.js b/packages/react/transform/crates/swc_plugin_shake/tests/__swc_snapshots__/lib.rs/should_remove_use_effect_param.js index fa66dc9461..63fc44b97a 100644 --- a/packages/react/transform/crates/swc_plugin_shake/tests/__swc_snapshots__/lib.rs/should_remove_use_effect_param.js +++ b/packages/react/transform/crates/swc_plugin_shake/tests/__swc_snapshots__/lib.rs/should_remove_use_effect_param.js @@ -1,4 +1,8 @@ import { useEffect } from "@lynx-js/react-runtime"; +const myUseEffect = useEffect; export function A() { useEffect(); + myUseEffect(()=>{ + console.log("remove myUseEffect"); + }); } diff --git a/packages/react/transform/crates/swc_plugin_shake/tests/__swc_snapshots__/lib.rs/should_replace_use_effect_call_with_undefined.js b/packages/react/transform/crates/swc_plugin_shake/tests/__swc_snapshots__/lib.rs/should_replace_use_effect_call_with_undefined.js new file mode 100644 index 0000000000..648a6e848d --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_shake/tests/__swc_snapshots__/lib.rs/should_replace_use_effect_call_with_undefined.js @@ -0,0 +1,2 @@ +import { useEffect } from '@lynx-js/react-runtime'; +const a = undefined; diff --git a/packages/react/transform/index.d.ts b/packages/react/transform/index.d.ts index e0ca50cae6..4e51258079 100644 --- a/packages/react/transform/index.d.ts +++ b/packages/react/transform/index.d.ts @@ -522,6 +522,32 @@ export interface ShakeVisitorConfig { * @public */ retainProp: Array + /** + * Function names whose calls should be replaced with `undefined` during transformation + * + * @example + * ```js + * import { defineConfig } from '@lynx-js/rspeedy' + * import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin' + * + * export default defineConfig({ + * plugins: [ + * pluginReactLynx({ + * shake: { + * removeCall: ['useMyCustomEffect'] + * } + * }) + * ] + * }) + * ``` + * + * @remarks + * Default value: `['useEffect', 'useLayoutEffect', '__runInJS', 'useLynxGlobalEventListener', 'useImperativeHandle']` + * The provided values will be merged with the default values instead of replacing them. + * + * @public + */ + removeCall: Array /** * Function names whose parameters should be removed during transformation * @@ -542,7 +568,7 @@ export interface ShakeVisitorConfig { * ``` * * @remarks - * Default value: `['useEffect', 'useLayoutEffect', '__runInJS', 'useLynxGlobalEventListener', 'useImperativeHandle']` + * Default value: `[]` * The provided values will be merged with the default values instead of replacing them. * * @public diff --git a/packages/react/transform/swc-plugin-reactlynx/index.d.ts b/packages/react/transform/swc-plugin-reactlynx/index.d.ts index 6f9369cc2c..7bc56efaa4 100644 --- a/packages/react/transform/swc-plugin-reactlynx/index.d.ts +++ b/packages/react/transform/swc-plugin-reactlynx/index.d.ts @@ -81,6 +81,32 @@ export interface ShakeVisitorConfig { * @public */ retainProp: Array; + /** + * Function names whose calls should be replaced with `undefined` during transformation + * + * @example + * ```js + * import { defineConfig } from '@lynx-js/rspeedy' + * import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin' + * + * export default defineConfig({ + * plugins: [ + * pluginReactLynx({ + * shake: { + * removeCall: ['useMyCustomEffect'] + * } + * }) + * ] + * }) + * ``` + * + * @remarks + * Default value: `['useEffect', 'useLayoutEffect', '__runInJS', 'useLynxGlobalEventListener', 'useImperativeHandle']` + * The provided values will be merged with the default values instead of replacing them. + * + * @public + */ + removeCall: Array; /** * Function names whose parameters should be removed during transformation * @@ -101,7 +127,7 @@ export interface ShakeVisitorConfig { * ``` * * @remarks - * Default value: `['useEffect', 'useLayoutEffect', '__runInJS', 'useLynxGlobalEventListener', 'useImperativeHandle']` + * Default value: `[]` * The provided values will be merged with the default values instead of replacing them. * * @public diff --git a/packages/rspeedy/plugin-react/etc/react-rsbuild-plugin.api.md b/packages/rspeedy/plugin-react/etc/react-rsbuild-plugin.api.md index 141b1b0530..bb87bef63e 100644 --- a/packages/rspeedy/plugin-react/etc/react-rsbuild-plugin.api.md +++ b/packages/rspeedy/plugin-react/etc/react-rsbuild-plugin.api.md @@ -94,6 +94,7 @@ export interface PluginReactLynxOptions { // @public export interface ShakeVisitorConfig { pkgName: Array + removeCall: Array removeCallParams: Array retainProp: Array } diff --git a/packages/webpack/react-refresh-webpack-plugin/package.json b/packages/webpack/react-refresh-webpack-plugin/package.json index a9ce53eb57..bc8abe96ca 100644 --- a/packages/webpack/react-refresh-webpack-plugin/package.json +++ b/packages/webpack/react-refresh-webpack-plugin/package.json @@ -48,7 +48,7 @@ "webpack": "^5.105.2" }, "peerDependencies": { - "@lynx-js/react-webpack-plugin": "^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0" + "@lynx-js/react-webpack-plugin": "^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0" }, "engines": { "node": ">=18" diff --git a/packages/webpack/react-webpack-plugin/src/loaders/options.ts b/packages/webpack/react-webpack-plugin/src/loaders/options.ts index 5c70935dda..46ee1ba4c4 100644 --- a/packages/webpack/react-webpack-plugin/src/loaders/options.ts +++ b/packages/webpack/react-webpack-plugin/src/loaders/options.ts @@ -257,14 +257,15 @@ export function getMainThreadTransformOptions( 'defaultProps', ...(shake?.retainProp ?? []), ], - removeCallParams: [ + removeCall: [ 'useEffect', 'useLayoutEffect', '__runInJS', 'useLynxGlobalEventListener', 'useImperativeHandle', - ...(shake?.removeCallParams ?? []), + ...(shake?.removeCall ?? []), ], + removeCallParams: shake?.removeCallParams ?? [], }, worklet: { ...commonOptions.worklet,