diff --git a/examples/remove-console/.gitignore b/examples/remove-console/.gitignore new file mode 100644 index 000000000000000..1437c53f70bc211 --- /dev/null +++ b/examples/remove-console/.gitignore @@ -0,0 +1,34 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel diff --git a/examples/remove-console/README.md b/examples/remove-console/README.md new file mode 100644 index 000000000000000..aeb9c98a281f0e3 --- /dev/null +++ b/examples/remove-console/README.md @@ -0,0 +1,27 @@ +# Remove Console Example + +This example shows how to use the `removeConsole` config option to remove all `console.*` calls. + +## Preview + +Preview the example live on [StackBlitz](http://stackblitz.com/): + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/vercel/next.js/tree/canary/examples/remove-console) + +## Deploy your own + +Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/vercel/next.js/tree/canary/examples/remove-console&project-name=remove-console&repository-name=remove-console) + +## How to use + +Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: + +```bash +npx create-next-app --example remove-console remove-console-app +# or +yarn create next-app --example remove-console remove-console-app +``` + +Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). diff --git a/examples/remove-console/next.config.js b/examples/remove-console/next.config.js new file mode 100644 index 000000000000000..afad83efd0b78b4 --- /dev/null +++ b/examples/remove-console/next.config.js @@ -0,0 +1,9 @@ +module.exports = { + experimental: { + removeConsole: { + exclude: ['error'], + }, + // Uncomment this to suppress all logs. + // removeConsole: true, + }, +} diff --git a/examples/remove-console/package.json b/examples/remove-console/package.json new file mode 100644 index 000000000000000..f9170ae254fa3b5 --- /dev/null +++ b/examples/remove-console/package.json @@ -0,0 +1,13 @@ +{ + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "latest", + "react": "^17.0.2", + "react-dom": "^17.0.2" + } +} diff --git a/examples/remove-console/pages/index.js b/examples/remove-console/pages/index.js new file mode 100644 index 000000000000000..802d5c9f9ae5346 --- /dev/null +++ b/examples/remove-console/pages/index.js @@ -0,0 +1,10 @@ +const Index = () => ( +
+

The console should be empty.

+
+) + +console.log('log from index.js') +console.error('error log from index.js') + +export default Index diff --git a/packages/next/build/swc/options.js b/packages/next/build/swc/options.js index 1475914d19271af..a058eca259fe689 100644 --- a/packages/next/build/swc/options.js +++ b/packages/next/build/swc/options.js @@ -66,6 +66,7 @@ function getBaseSWCOptions({ displayName: Boolean(development), } : null, + removeConsole: nextConfig?.experimental?.removeConsole, } } diff --git a/packages/next/build/swc/src/lib.rs b/packages/next/build/swc/src/lib.rs index 45d30fb928b1a2b..ba091950fb087e8 100644 --- a/packages/next/build/swc/src/lib.rs +++ b/packages/next/build/swc/src/lib.rs @@ -60,7 +60,9 @@ pub mod minify; pub mod next_dynamic; pub mod next_ssg; pub mod page_config; +pub mod remove_console; pub mod styled_jsx; +mod top_level_binding_collector; mod transform; mod util; @@ -87,6 +89,9 @@ pub struct TransformOptions { #[serde(default)] pub styled_components: Option, + + #[serde(default)] + pub remove_console: Option, } pub fn custom_before_pass(file: Arc, opts: &TransformOptions) -> impl Fold { @@ -113,7 +118,12 @@ pub fn custom_before_pass(file: Arc, opts: &TransformOptions) -> imp Optional::new( page_config::page_config(opts.is_development, opts.is_page_file), !opts.disable_page_config - ) + ), + match &opts.remove_console { + Some(config) if config.truthy() => + Either::Left(remove_console::remove_console(config.clone())), + _ => Either::Right(noop()), + }, ) } diff --git a/packages/next/build/swc/src/remove_console.rs b/packages/next/build/swc/src/remove_console.rs new file mode 100644 index 000000000000000..dd7cb46cb0f8a61 --- /dev/null +++ b/packages/next/build/swc/src/remove_console.rs @@ -0,0 +1,146 @@ +use serde::Deserialize; +use swc_atoms::JsWord; +use swc_common::collections::AHashSet; +use swc_common::DUMMY_SP; +use swc_ecmascript::ast::*; +use swc_ecmascript::utils::ident::IdentLike; +use swc_ecmascript::utils::Id; +use swc_ecmascript::visit::{noop_fold_type, Fold, FoldWith}; + +use crate::top_level_binding_collector::collect_top_level_decls; + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum Config { + All(bool), + WithOptions(Options), +} + +impl Config { + pub fn truthy(&self) -> bool { + match self { + Config::All(b) => *b, + Config::WithOptions(_) => true, + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Options { + #[serde(default)] + pub exclude: Vec, +} + +struct RemoveConsole { + exclude: Vec, + bindings: Vec>, + in_function_params: bool, +} + +impl RemoveConsole { + fn is_global_console(&self, ident: &Ident) -> bool { + &ident.sym == "console" + && self + .bindings + .iter() + .find(|x| x.contains(&ident.to_id())) + .is_none() + } + + fn should_remove_call(&mut self, n: &CallExpr) -> bool { + let callee = &n.callee; + let member_expr = match callee { + ExprOrSuper::Expr(e) => match &**e { + Expr::Member(m) => m, + _ => return false, + }, + _ => return false, + }; + + // Don't attempt to evaluate computed properties. + if member_expr.computed { + return false; + } + + // Only proceed if the object is the global `console` object. + match &member_expr.obj { + ExprOrSuper::Expr(e) => match &**e { + Expr::Ident(i) if self.is_global_console(i) => {} + _ => return false, + }, + _ => return false, + } + + // Check if the property is requested to be excluded. + // Here we do an O(n) search on the list of excluded properties because the size + // should be small. + match &*member_expr.prop { + Expr::Ident(i) if self.exclude.iter().find(|x| **x == i.sym).is_none() => {} + _ => return false, + } + + true + } +} + +impl Fold for RemoveConsole { + noop_fold_type!(); + + fn fold_stmt(&mut self, stmt: Stmt) -> Stmt { + match &stmt { + Stmt::Expr(e) => match &*e.expr { + Expr::Call(c) => { + if self.should_remove_call(c) { + return Stmt::Empty(EmptyStmt { span: DUMMY_SP }); + } + } + _ => {} + }, + _ => {} + } + stmt.fold_children_with(self) + } + + fn fold_function(&mut self, mut func: Function) -> Function { + self.in_function_params = true; + let mut new_params: AHashSet = AHashSet::default(); + for param in &func.params { + new_params.extend(collect_top_level_decls(param)); + } + self.in_function_params = false; + + self.bindings.push(new_params); + self.bindings.push(collect_top_level_decls(&func)); + func.body = func.body.fold_with(self); + self.bindings.pop().unwrap(); + self.bindings.pop().unwrap(); + func + } + + fn fold_module(&mut self, module: Module) -> Module { + self.bindings.push(collect_top_level_decls(&module)); + let m = module.fold_children_with(self); + self.bindings.pop().unwrap(); + m + } + + fn fold_script(&mut self, script: Script) -> Script { + self.bindings.push(collect_top_level_decls(&script)); + let s = script.fold_with(self); + self.bindings.pop().unwrap(); + s + } +} + +pub fn remove_console(config: Config) -> impl Fold { + let exclude = match config { + Config::WithOptions(x) => x.exclude, + _ => vec![], + }; + let remover = RemoveConsole { + exclude, + bindings: Default::default(), + in_function_params: false, + }; + remover +} diff --git a/packages/next/build/swc/src/top_level_binding_collector.rs b/packages/next/build/swc/src/top_level_binding_collector.rs new file mode 100644 index 000000000000000..a5b31386bc3fb7f --- /dev/null +++ b/packages/next/build/swc/src/top_level_binding_collector.rs @@ -0,0 +1,128 @@ +use std::hash::Hash; +use swc_common::{collections::AHashSet, SyntaxContext, DUMMY_SP}; +use swc_ecmascript::{ + ast::{ + ClassDecl, FnDecl, Ident, ImportDefaultSpecifier, ImportNamedSpecifier, + ImportStarAsSpecifier, Invalid, ModuleItem, ObjectPatProp, Param, Pat, Stmt, VarDeclarator, + }, + utils::ident::IdentLike, + visit::{noop_visit_type, Node, Visit, VisitWith}, +}; + +// Modified from swc_ecma_utils/src/lib.rs:BindingCollector. +pub struct TopLevelBindingCollector +where + I: IdentLike + Eq + Hash + Send + Sync, +{ + only: Option, + bindings: AHashSet, + is_pat_decl: bool, +} + +impl TopLevelBindingCollector +where + I: IdentLike + Eq + Hash + Send + Sync, +{ + fn add(&mut self, i: &Ident) { + if let Some(only) = self.only { + if only != i.span.ctxt { + return; + } + } + + self.bindings.insert(I::from_ident(i)); + } +} + +impl Visit for TopLevelBindingCollector +where + I: IdentLike + Eq + Hash + Send + Sync, +{ + noop_visit_type!(); + + fn visit_class_decl(&mut self, node: &ClassDecl, _: &dyn Node) { + self.add(&node.ident); + } + + fn visit_fn_decl(&mut self, node: &FnDecl, _: &dyn Node) { + self.add(&node.ident); + } + + fn visit_pat(&mut self, node: &Pat, _: &dyn Node) { + if self.is_pat_decl { + match node { + Pat::Ident(i) => self.add(&i.id), + Pat::Object(o) => { + for prop in o.props.iter() { + match prop { + ObjectPatProp::Assign(a) => self.add(&a.key), + ObjectPatProp::KeyValue(k) => k.value.visit_with(k, self), + ObjectPatProp::Rest(_) => {} + } + } + } + Pat::Array(a) => { + for elem in a.elems.iter() { + elem.visit_with(a, self); + } + } + _ => {} + } + } + } + + fn visit_param(&mut self, node: &Param, _: &dyn Node) { + let old = self.is_pat_decl; + self.is_pat_decl = true; + node.visit_children_with(self); + self.is_pat_decl = old; + } + + fn visit_import_default_specifier(&mut self, node: &ImportDefaultSpecifier, _: &dyn Node) { + self.add(&node.local); + } + + fn visit_import_named_specifier(&mut self, node: &ImportNamedSpecifier, _: &dyn Node) { + self.add(&node.local); + } + + fn visit_import_star_as_specifier(&mut self, node: &ImportStarAsSpecifier, _: &dyn Node) { + self.add(&node.local); + } + + fn visit_module_items(&mut self, nodes: &[ModuleItem], _: &dyn Node) { + for node in nodes { + node.visit_children_with(self) + } + } + + fn visit_stmts(&mut self, nodes: &[Stmt], _: &dyn Node) { + for node in nodes { + node.visit_children_with(self) + } + } + + fn visit_var_declarator(&mut self, node: &VarDeclarator, _: &dyn Node) { + let old = self.is_pat_decl; + self.is_pat_decl = true; + node.name.visit_with(node, self); + + self.is_pat_decl = false; + node.init.visit_with(node, self); + self.is_pat_decl = old; + } +} + +pub fn collect_top_level_decls(n: &N) -> AHashSet +where + I: IdentLike + Eq + Hash + Send + Sync, + N: VisitWith>, +{ + let mut v = TopLevelBindingCollector { + only: None, + bindings: Default::default(), + is_pat_decl: false, + }; + n.visit_with(&Invalid { span: DUMMY_SP }, &mut v); + v.bindings +} diff --git a/packages/next/build/swc/tests/fixture.rs b/packages/next/build/swc/tests/fixture.rs index e9bcdf4ae6cc33e..56ad91991da1b6a 100644 --- a/packages/next/build/swc/tests/fixture.rs +++ b/packages/next/build/swc/tests/fixture.rs @@ -1,6 +1,6 @@ use next_swc::{ amp_attributes::amp_attributes, next_dynamic::next_dynamic, next_ssg::next_ssg, - page_config::page_config_test, styled_jsx::styled_jsx, + page_config::page_config_test, remove_console::remove_console, styled_jsx::styled_jsx, }; use std::path::PathBuf; use swc_common::{chain, comments::SingleThreadedComments, FileName, Mark, Span, DUMMY_SP}; @@ -119,3 +119,14 @@ fn page_config_fixture(input: PathBuf) { let output = input.parent().unwrap().join("output.js"); test_fixture(syntax(), &|_tr| page_config_test(), &input, &output); } + +#[fixture("tests/fixture/remove-console/**/input.js")] +fn remove_console_fixture(input: PathBuf) { + let output = input.parent().unwrap().join("output.js"); + test_fixture( + syntax(), + &|_tr| remove_console(next_swc::remove_console::Config::All(true)), + &input, + &output, + ); +} diff --git a/packages/next/build/swc/tests/fixture/remove-console/all/simple/input.js b/packages/next/build/swc/tests/fixture/remove-console/all/simple/input.js new file mode 100644 index 000000000000000..1bb8ab88ac90c7d --- /dev/null +++ b/packages/next/build/swc/tests/fixture/remove-console/all/simple/input.js @@ -0,0 +1,42 @@ +console.log("remove console test at top level"); + +export function shouldRemove() { + console.log("remove console test in function"); + console.error("remove console test in function / error"); +} + +export function locallyDefinedConsole() { + let console = { + log: () => { }, + }; + console.log(); +} + +export function capturedConsole() { + let console = { + log: () => { }, + }; + function innerFunc() { + console.log(); + } +} + +export function overrideInParam(console) { + console.log("") +} + +export function overrideInParamObjectPatPropAssign({ console }) { + console.log("") +} + +export function overrideInParamObjectPatPropKeyValue({ c: console }) { + console.log("") +} + +export function overrideInParamObjectPatPropKeyValueNested({ c: { console } }) { + console.log("") +} + +export function overrideInParamArray([ console ]) { + console.log("") +} diff --git a/packages/next/build/swc/tests/fixture/remove-console/all/simple/output.js b/packages/next/build/swc/tests/fixture/remove-console/all/simple/output.js new file mode 100644 index 000000000000000..ee23146e5d62996 --- /dev/null +++ b/packages/next/build/swc/tests/fixture/remove-console/all/simple/output.js @@ -0,0 +1,36 @@ +; +export function shouldRemove() { + ; + ; +} +export function locallyDefinedConsole() { + let console = { + log: ()=>{ + } + }; + console.log(); +} +export function capturedConsole() { + let console = { + log: ()=>{ + } + }; + function innerFunc() { + console.log(); + } +} +export function overrideInParam(console) { + console.log(""); +} +export function overrideInParamObjectPatPropAssign({ console }) { + console.log(""); +} +export function overrideInParamObjectPatPropKeyValue({ c: console }) { + console.log(""); +} +export function overrideInParamObjectPatPropKeyValueNested({ c: { console } }) { + console.log(""); +} +export function overrideInParamArray([console]) { + console.log(""); +} diff --git a/packages/next/build/swc/tests/fixture/remove-console/all/toplevel-override/input.js b/packages/next/build/swc/tests/fixture/remove-console/all/toplevel-override/input.js new file mode 100644 index 000000000000000..150e9ddd78c3fb3 --- /dev/null +++ b/packages/next/build/swc/tests/fixture/remove-console/all/toplevel-override/input.js @@ -0,0 +1,9 @@ +let console = { + log: (msg) => {}, +}; + +function func1() { + console.log("remove console test in function"); +} + +console.log("remove console test at top level"); diff --git a/packages/next/build/swc/tests/fixture/remove-console/all/toplevel-override/output.js b/packages/next/build/swc/tests/fixture/remove-console/all/toplevel-override/output.js new file mode 100644 index 000000000000000..2d3b8aa02a89996 --- /dev/null +++ b/packages/next/build/swc/tests/fixture/remove-console/all/toplevel-override/output.js @@ -0,0 +1,8 @@ +let console = { + log: (msg)=>{ + } +}; +function func1() { + console.log("remove console test in function"); +} +console.log("remove console test at top level"); diff --git a/packages/next/build/swc/tests/full.rs b/packages/next/build/swc/tests/full.rs index 38ab5603ed6d317..9b1dbf6bc9db132 100644 --- a/packages/next/build/swc/tests/full.rs +++ b/packages/next/build/swc/tests/full.rs @@ -57,6 +57,7 @@ fn test(input: &Path, minify: bool) { is_page_file: false, is_development: true, styled_components: Some(assert_json("{}")), + remove_console: None, }; let options = options.patch(&fm); diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index aa20df71b079069..b15a32f39117a3e 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -126,6 +126,11 @@ export type NextConfig = { [key: string]: any } & { crossOrigin?: false | 'anonymous' | 'use-credentials' swcMinify?: boolean experimental?: { + removeConsole?: + | boolean + | { + exclude?: string[] + } styledComponents?: boolean swcMinify?: boolean cpus?: number