diff --git a/packages/next-swc/crates/core/src/lib.rs b/packages/next-swc/crates/core/src/lib.rs index 25ab91d72f896..825b24ea0bbb0 100644 --- a/packages/next-swc/crates/core/src/lib.rs +++ b/packages/next-swc/crates/core/src/lib.rs @@ -60,6 +60,7 @@ pub mod named_import_transform; pub mod next_dynamic; pub mod next_ssg; pub mod optimize_barrel; +pub mod optimize_server_react; pub mod page_config; pub mod react_remove_properties; pub mod react_server_components; @@ -147,6 +148,9 @@ pub struct TransformOptions { #[serde(default)] pub cjs_require_optimizer: Option, + + #[serde(default)] + pub optimize_server_react: Option, } pub fn custom_before_pass<'a, C: Comments + 'a>( @@ -265,6 +269,10 @@ where Some(config) => Either::Left(optimize_barrel::optimize_barrel(config.clone())), _ => Either::Right(noop()), }, + match &opts.optimize_server_react { + Some(config) => Either::Left(optimize_server_react::optimize_server_react(config.clone())), + _ => Either::Right(noop()), + }, opts.emotion .as_ref() .and_then(|config| { diff --git a/packages/next-swc/crates/core/src/optimize_server_react.rs b/packages/next-swc/crates/core/src/optimize_server_react.rs new file mode 100644 index 0000000000000..0931bccd4249d --- /dev/null +++ b/packages/next-swc/crates/core/src/optimize_server_react.rs @@ -0,0 +1,194 @@ +// This transform optimizes React code for the server bundle, in particular: +// - Removes `useEffect` and `useLayoutEffect` calls +// - Refactors `useState` calls (under the `optimize_use_state` flag) + +use serde::Deserialize; +use turbopack_binding::swc::core::{ + common::DUMMY_SP, + ecma::{ + ast::*, + visit::{Fold, FoldWith}, + }, +}; + +#[derive(Clone, Debug, Deserialize)] +pub struct Config { + pub optimize_use_state: bool, +} + +pub fn optimize_server_react(config: Config) -> impl Fold { + OptimizeServerReact { + optimize_use_state: config.optimize_use_state, + ..Default::default() + } +} + +#[derive(Debug, Default)] +struct OptimizeServerReact { + optimize_use_state: bool, + react_ident: Option, + use_state_ident: Option, + use_effect_ident: Option, + use_layout_effect_ident: Option, +} + +fn effect_has_side_effect_deps(call: &CallExpr) -> bool { + if call.args.len() != 2 { + return false; + } + + // We can't optimize if the effect has a function call as a dependency: + // useEffect(() => {}, x()) + if let box Expr::Call(_) = &call.args[1].expr { + return true; + } + + // As well as: + // useEffect(() => {}, [x()]) + if let box Expr::Array(arr) = &call.args[1].expr { + for elem in arr.elems.iter().flatten() { + if let ExprOrSpread { + expr: box Expr::Call(_), + .. + } = elem + { + return true; + } + } + } + + false +} + +impl Fold for OptimizeServerReact { + fn fold_module_items(&mut self, items: Vec) -> Vec { + let mut new_items = vec![]; + + for item in items { + new_items.push(item.clone().fold_with(self)); + + if let ModuleItem::ModuleDecl(ModuleDecl::Import(import_decl)) = &item { + if import_decl.src.value.to_string() != "react" { + continue; + } + for specifier in &import_decl.specifiers { + if let ImportSpecifier::Named(named_import) = specifier { + let name = match &named_import.imported { + Some(n) => match &n { + ModuleExportName::Ident(n) => n.sym.to_string(), + ModuleExportName::Str(n) => n.value.to_string(), + }, + None => named_import.local.sym.to_string(), + }; + + if name == "useState" { + self.use_state_ident = Some(named_import.local.to_id()); + } else if name == "useEffect" { + self.use_effect_ident = Some(named_import.local.to_id()); + } else if name == "useLayoutEffect" { + self.use_layout_effect_ident = Some(named_import.local.to_id()); + } + } else if let ImportSpecifier::Default(default_import) = specifier { + self.react_ident = Some(default_import.local.to_id()); + } + } + } + } + + new_items + } + + fn fold_expr(&mut self, expr: Expr) -> Expr { + if let Expr::Call(call) = &expr { + if let Callee::Expr(box Expr::Ident(f)) = &call.callee { + // Remove `useEffect` call + if let Some(use_effect_ident) = &self.use_effect_ident { + if &f.to_id() == use_effect_ident && !effect_has_side_effect_deps(call) { + return Expr::Lit(Lit::Null(Null { span: DUMMY_SP })); + } + } + // Remove `useLayoutEffect` call + if let Some(use_layout_effect_ident) = &self.use_layout_effect_ident { + if &f.to_id() == use_layout_effect_ident && !effect_has_side_effect_deps(call) { + return Expr::Lit(Lit::Null(Null { span: DUMMY_SP })); + } + } + } else if let Some(react_ident) = &self.react_ident { + if let Callee::Expr(box Expr::Member(member)) = &call.callee { + if let box Expr::Ident(f) = &member.obj { + if &f.to_id() == react_ident { + if let MemberProp::Ident(i) = &member.prop { + // Remove `React.useEffect` and `React.useLayoutEffect` calls + if i.sym.to_string() == "useEffect" + || i.sym.to_string() == "useLayoutEffect" + { + return Expr::Lit(Lit::Null(Null { span: DUMMY_SP })); + } + } + } + } + } + } + } + + expr + } + + // const [state, setState] = useState(x); + // const [state, setState] = React.useState(x); + fn fold_var_declarators(&mut self, d: Vec) -> Vec { + if !self.optimize_use_state { + return d; + } + + let mut new_d = vec![]; + for decl in d { + if let Pat::Array(array_pat) = &decl.name { + if array_pat.elems.len() == 2 { + if let Some(array_pat_1) = &array_pat.elems[0] { + if let Some(array_pat_2) = &array_pat.elems[1] { + if let Some(box Expr::Call(call)) = &decl.init { + if let Callee::Expr(box Expr::Ident(f)) = &call.callee { + if let Some(use_state_ident) = &self.use_state_ident { + if &f.to_id() == use_state_ident && call.args.len() == 1 { + // const state = x, setState = () => {}; + new_d.push(VarDeclarator { + definite: false, + name: array_pat_1.clone(), + init: Some(call.args[0].expr.clone()), + span: DUMMY_SP, + }); + new_d.push(VarDeclarator { + definite: false, + name: array_pat_2.clone(), + init: Some(Box::new(Expr::Arrow(ArrowExpr { + body: Box::new(BlockStmtOrExpr::Expr( + Box::new(Expr::Lit(Lit::Null(Null { + span: DUMMY_SP, + }))), + )), + params: vec![], + is_async: false, + is_generator: false, + span: DUMMY_SP, + type_params: None, + return_type: None, + }))), + span: DUMMY_SP, + }); + continue; + } + } + } + } + } + } + } + } + + new_d.push(decl.fold_with(self)); + } + + new_d + } +} diff --git a/packages/next-swc/crates/core/tests/fixture.rs b/packages/next-swc/crates/core/tests/fixture.rs index 418e2c480206a..bebeccc364b6e 100644 --- a/packages/next-swc/crates/core/tests/fixture.rs +++ b/packages/next-swc/crates/core/tests/fixture.rs @@ -7,6 +7,7 @@ use next_swc::{ next_dynamic::next_dynamic, next_ssg::next_ssg, optimize_barrel::optimize_barrel, + optimize_server_react::optimize_server_react, page_config::page_config_test, react_remove_properties::remove_properties, react_server_components::server_components, @@ -580,6 +581,28 @@ fn optimize_barrel_wildcard_fixture(input: PathBuf) { ); } +#[fixture("tests/fixture/optimize_server_react/**/input.js")] +fn optimize_server_react_fixture(input: PathBuf) { + let output = input.parent().unwrap().join("output.js"); + test_fixture( + syntax(), + &|_tr| { + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + + chain!( + resolver(unresolved_mark, top_level_mark, false), + optimize_server_react(next_swc::optimize_server_react::Config { + optimize_use_state: true + }) + ) + }, + &input, + &output, + Default::default(), + ); +} + fn json(s: &str) -> T where T: DeserializeOwned, diff --git a/packages/next-swc/crates/core/tests/fixture/optimize_server_react/1/input.js b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/1/input.js new file mode 100644 index 0000000000000..207f614eaf119 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/1/input.js @@ -0,0 +1,31 @@ +import { useEffect, useLayoutEffect, useMemo } from 'react' +import React from 'react' + +export default function App() { + useEffect(() => { + console.log('Hello World') + }, []) + + useLayoutEffect(() => { + function foo() {} + return () => {} + }, [1, 2, App]) + + useLayoutEffect(() => {}, [runSideEffect()]) + useEffect(() => {}, [1, runSideEffect(), 2]) + useEffect(() => {}, getArray()) + + const a = useMemo(() => { + return 1 + }, []) + + React.useEffect(() => { + console.log('Hello World') + }) + + return ( +
+

Hello World

+
+ ) +} diff --git a/packages/next-swc/crates/core/tests/fixture/optimize_server_react/1/output.js b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/1/output.js new file mode 100644 index 0000000000000..af3a9326a47f7 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/1/output.js @@ -0,0 +1,24 @@ +import { useEffect, useLayoutEffect, useMemo } from 'react'; +import React from 'react'; +export default function App() { + null; + null; + useLayoutEffect(()=>{}, [ + runSideEffect() + ]); + useEffect(()=>{}, [ + 1, + runSideEffect(), + 2 + ]); + useEffect(()=>{}, getArray()); + const a = useMemo(()=>{ + return 1; + }, []); + null; + return
+ +

Hello World

+ +
; +} diff --git a/packages/next-swc/crates/core/tests/fixture/optimize_server_react/2/input.js b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/2/input.js new file mode 100644 index 0000000000000..c0699c0fec2dc --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/2/input.js @@ -0,0 +1,65 @@ +// https://github.com/vercel/commerce/blob/18167d22f31fce6c90f98912e514243236200989/components/layout/search/filter/dropdown.tsx#L16 + +'use client' + +import { usePathname, useSearchParams } from 'next/navigation' +import { useEffect, useRef, useState } from 'react' + +import { ChevronDownIcon } from '@heroicons/react/24/outline' +import { FilterItem } from './item' + +export default function FilterItemDropdown({ list }) { + const pathname = usePathname() + const searchParams = useSearchParams() + const [active, setActive] = useState('') + const [openSelect, setOpenSelect] = useState(false) + const ref = useRef(null) + + useEffect(() => { + const handleClickOutside = (event) => { + if (ref.current && !ref.current.contains(event.target)) { + setOpenSelect(false) + } + } + + window.addEventListener('click', handleClickOutside) + return () => window.removeEventListener('click', handleClickOutside) + }, []) + + useEffect(() => { + list.forEach((listItem) => { + if ( + ('path' in listItem && pathname === listItem.path) || + ('slug' in listItem && searchParams.get('sort') === listItem.slug) + ) { + setActive(listItem.title) + } + }) + }, [pathname, list, searchParams]) + + return ( +
+
{ + setOpenSelect(!openSelect) + }} + className="flex w-full items-center justify-between rounded border border-black/30 px-4 py-2 text-sm dark:border-white/30" + > +
{active}
+ +
+ {openSelect && ( +
{ + setOpenSelect(false) + }} + className="absolute z-40 w-full rounded-b-md bg-white p-4 shadow-md dark:bg-black" + > + {list.map((item, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/packages/next-swc/crates/core/tests/fixture/optimize_server_react/2/output.js b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/2/output.js new file mode 100644 index 0000000000000..fc9c7ae5f0147 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/2/output.js @@ -0,0 +1,36 @@ +// https://github.com/vercel/commerce/blob/18167d22f31fce6c90f98912e514243236200989/components/layout/search/filter/dropdown.tsx#L16 +'use client'; +import { usePathname, useSearchParams } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; +import { FilterItem } from './item'; +export default function FilterItemDropdown({ list }) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const active = '', setActive = ()=>null; + const openSelect = false, setOpenSelect = ()=>null; + const ref = useRef(null); + null; + null; + return
+ +
{ + setOpenSelect(!openSelect); + }} className="flex w-full items-center justify-between rounded border border-black/30 px-4 py-2 text-sm dark:border-white/30"> + +
{active}
+ + + +
+ + {openSelect &&
{ + setOpenSelect(false); + }} className="absolute z-40 w-full rounded-b-md bg-white p-4 shadow-md dark:bg-black"> + + {list.map((item, i)=>)} + +
} + +
; +} diff --git a/packages/next-swc/crates/core/tests/fixture/optimize_server_react/3/input.js b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/3/input.js new file mode 100644 index 0000000000000..226c6d52162a9 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/3/input.js @@ -0,0 +1,16 @@ +import { useState } from 'react' + +export default function App({ x }) { + const [state, setState] = useState(0) + const [state2, setState2] = useState(() => 0) + const [state3, setState3] = useState(x) + const s = useState(0) + const [state4] = useState(0) + const [{ a }, setState5] = useState({ a: 0 }) + + return ( +
+

Hello World

+
+ ) +} diff --git a/packages/next-swc/crates/core/tests/fixture/optimize_server_react/3/output.js b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/3/output.js new file mode 100644 index 0000000000000..6fda0ba75a8eb --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/3/output.js @@ -0,0 +1,16 @@ +import { useState } from 'react'; +export default function App({ x }) { + const state = 0, setState = ()=>null; + const state2 = ()=>0, setState2 = ()=>null; + const state3 = x, setState3 = ()=>null; + const s = useState(0); + const [state4] = useState(0); + const { a } = { + a: 0 + }, setState5 = ()=>null; + return
+ +

Hello World

+ +
; +} diff --git a/packages/next-swc/crates/core/tests/fixture/optimize_server_react/4/input.js b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/4/input.js new file mode 100644 index 0000000000000..3ce1633288079 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/4/input.js @@ -0,0 +1,28 @@ +const useEffect = 1 +import { useLayoutEffect, useMemo } from 'react' +const React = 2 + +export default function App() { + useEffect(() => { + console.log('Hello World') + }, []) + + useLayoutEffect(() => { + function foo() {} + return () => {} + }, [1, 2, App]) + + const a = useMemo(() => { + return 1 + }, []) + + React.useEffect(() => { + console.log('Hello World') + }) + + return ( +
+

Hello World

+
+ ) +} diff --git a/packages/next-swc/crates/core/tests/fixture/optimize_server_react/4/output.js b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/4/output.js new file mode 100644 index 0000000000000..fe84ce61e3181 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/4/output.js @@ -0,0 +1,20 @@ +const useEffect = 1; +import { useLayoutEffect, useMemo } from 'react'; +const React = 2; +export default function App() { + useEffect(()=>{ + console.log('Hello World'); + }, []); + null; + const a = useMemo(()=>{ + return 1; + }, []); + React.useEffect(()=>{ + console.log('Hello World'); + }); + return
+ +

Hello World

+ +
; +} diff --git a/packages/next-swc/crates/core/tests/fixture/optimize_server_react/5/input.js b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/5/input.js new file mode 100644 index 0000000000000..4e53effb26f73 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/5/input.js @@ -0,0 +1,15 @@ +import { ClientComponent } from './ClientComponent' + +export default async function Page() { + return ( + <> +
+ This fixture is to assert where the bootstrap scripts and other required + scripts emit during SSR +
+
+ +
+ + ) +} diff --git a/packages/next-swc/crates/core/tests/fixture/optimize_server_react/5/output.js b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/5/output.js new file mode 100644 index 0000000000000..fe2211d46a481 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/optimize_server_react/5/output.js @@ -0,0 +1,20 @@ +import { ClientComponent } from './ClientComponent'; +export default async function Page() { + return <> + +
+ + This fixture is to assert where the bootstrap scripts and other required + + scripts emit during SSR + +
+ +
+ + + +
+ + ; +} diff --git a/packages/next-swc/crates/core/tests/full.rs b/packages/next-swc/crates/core/tests/full.rs index 3e95881ca0055..e932b79f3930b 100644 --- a/packages/next-swc/crates/core/tests/full.rs +++ b/packages/next-swc/crates/core/tests/full.rs @@ -80,6 +80,7 @@ fn test(input: &Path, minify: bool) { cjs_require_optimizer: None, auto_modularize_imports: None, optimize_barrel_exports: None, + optimize_server_react: None, disable_checks: false, }; diff --git a/packages/next/src/build/swc/options.ts b/packages/next/src/build/swc/options.ts index ebb398b89831f..7b491a2ad400f 100644 --- a/packages/next/src/build/swc/options.ts +++ b/packages/next/src/build/swc/options.ts @@ -304,6 +304,7 @@ export function getLoaderSWCOptions({ isPageFile, hasReactRefresh, modularizeImports, + optimizeServerReact, optimizePackageImports, swcPlugins, compilerOptions, @@ -325,6 +326,7 @@ export function getLoaderSWCOptions({ appDir: string isPageFile: boolean hasReactRefresh: boolean + optimizeServerReact?: boolean modularizeImports: NextConfig['modularizeImports'] optimizePackageImports?: NonNullable< NextConfig['experimental'] @@ -380,6 +382,12 @@ export function getLoaderSWCOptions({ }, } + if (optimizeServerReact && isServer && !development) { + baseOptions.optimizeServerReact = { + optimize_use_state: true, + } + } + // Modularize import optimization for barrel files if (optimizePackageImports) { baseOptions.autoModularizeImports = { diff --git a/packages/next/src/build/webpack/loaders/next-swc-loader.ts b/packages/next/src/build/webpack/loaders/next-swc-loader.ts index 4426ca9ab9fee..c52ae3e38803a 100644 --- a/packages/next/src/build/webpack/loaders/next-swc-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-swc-loader.ts @@ -77,6 +77,7 @@ async function loaderTransform( optimizePackageImports: nextConfig?.experimental?.optimizePackageImports, swcPlugins: nextConfig?.experimental?.swcPlugins, compilerOptions: nextConfig?.compiler, + optimizeServerReact: nextConfig?.experimental?.optimizeServerReact, jsConfig, supportedBrowsers, swcCacheDir, diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index e93ab0be3430e..31cb19a7b2649 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -461,6 +461,9 @@ const configSchema = { optimizePackageImports: { type: 'array', }, + optimizeServerReact: { + type: 'boolean', + }, instrumentationHook: { type: 'boolean', }, diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 7f6e427c71866..113152dcf018e 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -232,6 +232,11 @@ export interface ExperimentalConfig { */ optimizePackageImports?: string[] + /** + * Optimize React APIs for server builds. + */ + optimizeServerReact?: boolean + turbo?: ExperimentalTurboOptions turbotrace?: { logLevel?: