diff --git a/packages/next-swc/crates/core/src/server_actions.rs b/packages/next-swc/crates/core/src/server_actions.rs index 33f3b6a7c42963..5358b98b451eea 100644 --- a/packages/next-swc/crates/core/src/server_actions.rs +++ b/packages/next-swc/crates/core/src/server_actions.rs @@ -102,6 +102,18 @@ impl ServerActions { remove_directive, &mut is_action_fn, ); + + if is_action_fn && !self.config.is_server { + HANDLER.with(|handler| { + handler + .struct_span_err( + body.span, + "\"use server\" functions are not allowed in client components. \ + You can import them from a \"use server\" file instead.", + ) + .emit() + }); + } } } @@ -692,9 +704,11 @@ impl VisitMut for ServerActions { stmt.visit_mut_with(self); - new.push(stmt); - new.extend(self.annotations.drain(..).map(ModuleItem::Stmt)); - new.append(&mut self.extra_items); + if self.config.is_server || !self.in_action_file { + new.push(stmt); + new.extend(self.annotations.drain(..).map(ModuleItem::Stmt)); + new.append(&mut self.extra_items); + } } // If it's a "use server" file, all exports need to be annotated as actions. @@ -703,11 +717,70 @@ impl VisitMut for ServerActions { let ident = Ident::new(id.0.clone(), DUMMY_SP.with_ctxt(id.1)); annotate_ident_as_action( &mut self.annotations, - ident, + ident.clone(), Vec::new(), self.file_name.to_string(), export_name.to_string(), ); + if !self.config.is_server { + let params_ident = private_ident!("args"); + let noop_fn = Box::new(Function { + params: vec![Param { + span: DUMMY_SP, + decorators: Default::default(), + pat: Pat::Rest(RestPat { + span: DUMMY_SP, + dot3_token: DUMMY_SP, + arg: Box::new(Pat::Ident(params_ident.clone().into())), + type_ann: None, + }), + }], + decorators: Vec::new(), + span: DUMMY_SP, + body: Some(BlockStmt { + span: DUMMY_SP, + stmts: vec![Stmt::Return(ReturnStmt { + span: DUMMY_SP, + arg: Some(Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: Callee::Expr(Box::new(Expr::Ident(private_ident!( + "__build_action__" + )))), + args: vec![ident.clone().as_arg(), params_ident.as_arg()], + type_args: None, + }))), + })], + }), + is_generator: false, + is_async: true, + type_params: None, + return_type: None, + }); + + if export_name == "default" { + let export_expr = ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr( + ExportDefaultExpr { + span: DUMMY_SP, + expr: Box::new(Expr::Fn(FnExpr { + ident: Some(ident), + function: noop_fn, + })), + }, + )); + new.push(export_expr); + } else { + let export_expr = + ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: Decl::Fn(FnDecl { + ident, + declare: false, + function: noop_fn, + }), + })); + new.push(export_expr); + } + } } new.extend(self.annotations.drain(..).map(ModuleItem::Stmt)); new.append(&mut self.extra_items); diff --git a/packages/next-swc/crates/core/tests/fixture.rs b/packages/next-swc/crates/core/tests/fixture.rs index 0c85c1aaac7373..1b3e8b002b71a7 100644 --- a/packages/next-swc/crates/core/tests/fixture.rs +++ b/packages/next-swc/crates/core/tests/fixture.rs @@ -301,8 +301,8 @@ fn next_font_loaders_fixture(input: PathBuf) { ); } -#[fixture("tests/fixture/server-actions/**/input.js")] -fn server_actions_fixture(input: PathBuf) { +#[fixture("tests/fixture/server-actions/server/**/input.js")] +fn server_actions_server_fixture(input: PathBuf) { let output = input.parent().unwrap().join("output.js"); test_fixture( syntax(), @@ -321,3 +321,24 @@ fn server_actions_fixture(input: PathBuf) { Default::default(), ); } + +#[fixture("tests/fixture/server-actions/client/**/input.js")] +fn server_actions_client_fixture(input: PathBuf) { + let output = input.parent().unwrap().join("output.js"); + test_fixture( + syntax(), + &|_tr| { + chain!( + resolver(Mark::new(), Mark::new(), false), + server_actions( + &FileName::Real("/app/item.js".into()), + server_actions::Config { is_server: false }, + _tr.comments.as_ref().clone(), + ) + ) + }, + &input, + &output, + Default::default(), + ); +} diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/client/1/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/client/1/input.js new file mode 100644 index 00000000000000..af5cf3d448fc17 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/server-actions/client/1/input.js @@ -0,0 +1,6 @@ +// app/send.ts +"use server"; +export async function myAction(a, b, c) { + console.log('a') +} +export default async function () {} diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/client/1/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/client/1/output.js new file mode 100644 index 00000000000000..01d1768d47e0e1 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/server-actions/client/1/output.js @@ -0,0 +1,13 @@ +// app/send.ts +/* __next_internal_action_entry_do_not_use__ myAction,default */ export async function myAction(...args) { + return __build_action__(myAction, args); +} +export default async function $$ACTION_0(...args) { + return __build_action__($$ACTION_0, args); +}; +myAction.$$typeof = Symbol.for("react.server.reference"); +myAction.$$id = "e10665baac148856374b2789aceb970f66fec33e"; +myAction.$$bound = []; +$$ACTION_0.$$typeof = Symbol.for("react.server.reference"); +$$ACTION_0.$$id = "c18c215a6b7cdc64bf709f3a714ffdef1bf9651d"; +$$ACTION_0.$$bound = []; diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/client/2/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/client/2/input.js new file mode 100644 index 00000000000000..06d78fb947c7c3 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/server-actions/client/2/input.js @@ -0,0 +1,11 @@ +// app/send.ts +"use server"; + +import 'db' + +console.log('side effect') + +const foo = async () => { + console.log('function body') +} +export { foo } \ No newline at end of file diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/client/2/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/client/2/output.js new file mode 100644 index 00000000000000..c54eb9844d5d54 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/server-actions/client/2/output.js @@ -0,0 +1,7 @@ +// app/send.ts +/* __next_internal_action_entry_do_not_use__ foo */ export async function foo(...args) { + return __build_action__(foo, args); +} +foo.$$typeof = Symbol.for("react.server.reference"); +foo.$$id = "ab21efdafbe611287bc25c0462b1e0510d13e48b"; +foo.$$bound = []; diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/1/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/1/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/1/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/1/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/1/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/1/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/1/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/1/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/10/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/10/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/10/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/10/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/10/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/10/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/10/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/10/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/11/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/11/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/11/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/11/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/11/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/11/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/11/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/11/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/12/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/12/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/12/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/12/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/12/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/12/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/12/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/12/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/13/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/13/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/13/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/13/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/13/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/13/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/13/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/13/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/14/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/14/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/14/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/14/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/14/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/14/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/14/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/14/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/15/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/15/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/15/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/15/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/15/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/15/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/15/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/15/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/16/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/16/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/16/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/16/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/16/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/16/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/16/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/16/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/17/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/17/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/17/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/17/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/17/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/17/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/17/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/17/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/18/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/18/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/18/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/18/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/18/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/18/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/18/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/18/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/2/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/2/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/2/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/2/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/2/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/2/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/2/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/2/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/3/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/3/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/3/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/3/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/3/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/3/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/3/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/3/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/4/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/4/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/4/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/4/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/4/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/4/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/4/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/4/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/5/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/5/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/5/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/5/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/5/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/5/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/5/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/5/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/6/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/6/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/6/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/6/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/6/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/6/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/6/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/6/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/7/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/7/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/7/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/7/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/7/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/7/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/7/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/7/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/8/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/8/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/8/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/8/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/8/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/8/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/8/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/8/output.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/9/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/9/input.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/9/input.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/9/input.js diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/9/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/server/9/output.js similarity index 100% rename from packages/next-swc/crates/core/tests/fixture/server-actions/9/output.js rename to packages/next-swc/crates/core/tests/fixture/server-actions/server/9/output.js diff --git a/packages/next/src/build/analysis/get-page-static-info.ts b/packages/next/src/build/analysis/get-page-static-info.ts index 7be04221f46b36..bbdfded0d8b500 100644 --- a/packages/next/src/build/analysis/get-page-static-info.ts +++ b/packages/next/src/build/analysis/get-page-static-info.ts @@ -47,10 +47,21 @@ const ACTION_MODULE_LABEL = /\/\* __next_internal_action_entry_do_not_use__ ([^ ]+) \*\// export type RSCModuleType = 'server' | 'client' -export function getRSCModuleInformation(source: string): RSCMeta { - const clientRefs = source.match(CLIENT_MODULE_LABEL)?.[1]?.split(',') +export function getRSCModuleInformation( + source: string, + isServerLayer = true +): RSCMeta { const actions = source.match(ACTION_MODULE_LABEL)?.[1]?.split(',') + if (!isServerLayer) { + return { + type: RSC_MODULE_TYPES.client, + actions, + } + } + + const clientRefs = source.match(CLIENT_MODULE_LABEL)?.[1]?.split(',') + const type = clientRefs ? RSC_MODULE_TYPES.client : RSC_MODULE_TYPES.server return { type, actions, clientRefs } } diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 8c9ce1a05f9315..d05c2c48001c6c 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -786,13 +786,22 @@ export default async function getBaseWebpackConfig( const swcLoaderForServerLayer = hasServerComponents ? useSWCLoader - ? getSwcLoader({ isServerLayer: true }) + ? [getSwcLoader({ isServerLayer: true })] : // When using Babel, we will have to add the SWC loader // as an additional pass to handle RSC correctly. // This will cause some performance overhead but // acceptable as Babel will not be recommended. [getSwcLoader({ isServerLayer: true }), getBabelLoader()] : [] + const swcLoaderForClientLayer = hasServerComponents + ? useSWCLoader + ? [getSwcLoader({ isServerLayer: false })] + : // When using Babel, we will have to add the SWC loader + // as an additional pass to handle RSC correctly. + // This will cause some performance overhead but + // acceptable as Babel will not be recommended. + [getSwcLoader({ isServerLayer: false }), getBabelLoader()] + : [] // Loader for API routes needs to be differently configured as it shouldn't // have RSC transpiler enabled, so syntax checks such as invalid imports won't @@ -1699,6 +1708,7 @@ export default async function getBaseWebpackConfig( 'next-flight-loader', 'next-flight-client-entry-loader', 'next-flight-action-entry-loader', + 'next-flight-client-action-loader', 'noop-loader', 'next-middleware-loader', 'next-edge-function-loader', @@ -1727,7 +1737,9 @@ export default async function getBaseWebpackConfig( ...(hasAppDir && !isClient ? [ { - issuerLayer: WEBPACK_LAYERS.server, + issuerLayer: { + or: [WEBPACK_LAYERS.server, WEBPACK_LAYERS.action], + }, test: { // Resolve it if it is a source code file, and it has NOT been // opted out of bundling. @@ -1796,6 +1808,7 @@ export default async function getBaseWebpackConfig( WEBPACK_LAYERS.server, WEBPACK_LAYERS.client, WEBPACK_LAYERS.appClient, + WEBPACK_LAYERS.action, ], }, resolve: { @@ -1817,7 +1830,9 @@ export default async function getBaseWebpackConfig( oneOf: [ { exclude: [staticGenerationAsyncStorageRegex], - issuerLayer: WEBPACK_LAYERS.server, + issuerLayer: { + or: [WEBPACK_LAYERS.server, WEBPACK_LAYERS.action], + }, test: { // Resolve it if it is a source code file, and it has NOT been // opted out of bundling. @@ -1888,7 +1903,9 @@ export default async function getBaseWebpackConfig( ? [ { test: codeCondition.test, - issuerLayer: WEBPACK_LAYERS.server, + issuerLayer: { + or: [WEBPACK_LAYERS.server, WEBPACK_LAYERS.action], + }, exclude: [staticGenerationAsyncStorageRegex], use: swcLoaderForServerLayer, }, @@ -1897,6 +1914,27 @@ export default async function getBaseWebpackConfig( resourceQuery: /__edge_ssr_entry__/, use: swcLoaderForServerLayer, }, + { + test: codeCondition.test, + issuerLayer: { + or: [WEBPACK_LAYERS.client, WEBPACK_LAYERS.appClient], + }, + exclude: [staticGenerationAsyncStorageRegex], + use: [ + ...(dev && isClient + ? [ + require.resolve( + 'next/dist/compiled/@next/react-refresh-utils/dist/loader' + ), + defaultLoaders.babel, + ] + : []), + { + loader: 'next-flight-client-action-loader', + }, + ...swcLoaderForClientLayer, + ], + }, ] : []), { @@ -2037,7 +2075,9 @@ export default async function getBaseWebpackConfig( { test: /node_modules[/\\]client-only[/\\]error.js/, loader: 'next-invalid-import-error-loader', - issuerLayer: WEBPACK_LAYERS.server, + issuerLayer: { + or: [WEBPACK_LAYERS.server, WEBPACK_LAYERS.action], + }, options: { message: "'client-only' cannot be imported from a Server Component module. It should only be used from a Client Component.", diff --git a/packages/next/src/build/webpack/loaders/next-flight-client-action-loader.ts b/packages/next/src/build/webpack/loaders/next-flight-client-action-loader.ts new file mode 100644 index 00000000000000..02fb73af97082f --- /dev/null +++ b/packages/next/src/build/webpack/loaders/next-flight-client-action-loader.ts @@ -0,0 +1,35 @@ +import { getRSCModuleInformation } from './../../analysis/get-page-static-info' +import { getModuleBuildInfo } from './get-module-build-info' + +export default async function transformSource( + this: any, + source: string, + sourceMap: any +) { + // Avoid buffer to be consumed + if (typeof source !== 'string') { + throw new Error('Expected source to have been transformed to a string.') + } + + const callback = this.async() + + // Assign the RSC meta information to buildInfo. + const buildInfo = getModuleBuildInfo(this._module) + buildInfo.rsc = getRSCModuleInformation(source, false) + + // This is a server action entry module in the client layer. We need to attach + // noop exports of `callServer` wrappers for each action. + if (buildInfo.rsc?.actions) { + source = ` +import { callServer } from 'next/dist/client/app-call-server' + +function __build_action__(action, args) { + return callServer(action.$$id, args) +} + +${source} +` + } + + return callback(null, source, sourceMap) +} diff --git a/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts b/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts index 329700ac222f3d..6f6327bfae1032 100644 --- a/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts +++ b/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts @@ -29,9 +29,8 @@ import { isClientComponentModule, regexCSS, } from '../loaders/utils' -import { traverseModules } from '../utils' +import { traverseModules, forEachEntryModule } from '../utils' import { normalizePathSep } from '../../../shared/lib/page-path/normalize-path-sep' -import { isAppRouteRoute } from '../../../lib/is-app-route-route' import { getProxiedPluginState } from '../../build-context' interface Options { @@ -98,9 +97,9 @@ export class FlightClientEntryPlugin { } ) - compiler.hooks.finishMake.tapPromise(PLUGIN_NAME, (compilation) => { - return this.createClientEntries(compiler, compilation) - }) + compiler.hooks.finishMake.tapPromise(PLUGIN_NAME, (compilation) => + this.createClientEntries(compiler, compilation) + ) compiler.hooks.afterCompile.tap(PLUGIN_NAME, (compilation) => { const recordModule = (modId: string, mod: any) => { @@ -166,60 +165,9 @@ export class FlightClientEntryPlugin { const addActionEntryList: Array> = [] - // Loop over all the entry modules. - function forEachEntryModule( - callback: ({ - name, - entryModule, - }: { - name: string - entryModule: any - }) => void - ) { - for (const [name, entry] of compilation.entries.entries()) { - // Skip for entries under pages/ - if ( - name.startsWith('pages/') || - // Skip for route.js entries - (name.startsWith('app/') && isAppRouteRoute(name)) - ) { - continue - } - - // Check if the page entry is a server component or not. - const entryDependency: webpack.NormalModule | undefined = - entry.dependencies?.[0] - // Ensure only next-app-loader entries are handled. - if (!entryDependency || !entryDependency.request) continue - - const request = entryDependency.request - - if ( - !request.startsWith('next-edge-ssr-loader?') && - !request.startsWith('next-app-loader?') - ) - continue - - let entryModule: webpack.NormalModule = - compilation.moduleGraph.getResolvedModule(entryDependency) - - if (request.startsWith('next-edge-ssr-loader?')) { - entryModule.dependencies.forEach((dependency) => { - const modRequest: string | undefined = (dependency as any).request - if (modRequest?.includes('next-app-loader')) { - entryModule = - compilation.moduleGraph.getResolvedModule(dependency) - } - }) - } - - callback({ name, entryModule }) - } - } - // For each SC server compilation entry, we need to create its corresponding // client component entry. - forEachEntryModule(({ name, entryModule }) => { + forEachEntryModule(compilation, ({ name, entryModule }) => { const internalClientComponentEntryImports = new Set< ClientComponentImports[0] >() @@ -305,6 +253,85 @@ export class FlightClientEntryPlugin { } }) + // To collect all CSS imports and action imports for a specific entry + // including the ones that are in the client graph, we need to store a + // map for client boundary dependencies. + function collectClientEntryDependencyMap(name: string) { + const clientEntryDependencyMap: Record = {} + + const entry = compilation.entries.get(name) + entry.includeDependencies.forEach((dep: any) => { + if ( + dep.request && + dep.request.startsWith('next-flight-client-entry-loader?') + ) { + const mod: webpack.NormalModule = + compilation.moduleGraph.getResolvedModule(dep) + + compilation.moduleGraph + .getOutgoingConnections(mod) + .forEach((connection: any) => { + if (connection.dependency) { + clientEntryDependencyMap[connection.dependency.request] = + connection.dependency + } + }) + } + }) + + return clientEntryDependencyMap + } + + // We need to create extra action entries that are created in the + // client layer. + compilation.hooks.finishModules.tapPromise(PLUGIN_NAME, () => { + const addedClientActionEntryList: Promise[] = [] + + forEachEntryModule(compilation, ({ name, entryModule }) => { + const actionEntryImports = new Map() + const clientEntryDependencyMap = collectClientEntryDependencyMap(name) + + const tracked = new Set() + for (const connection of compilation.moduleGraph.getOutgoingConnections( + entryModule + )) { + const entryDependency = connection.dependency + const entryRequest = connection.dependency.request + + // It is possible that the same entry is added multiple times in the + // connection graph. We can just skip these to speed up the process. + if (tracked.has(entryRequest)) continue + tracked.add(entryRequest) + + const { clientActionImports } = + this.collectComponentInfoFromDependencies({ + entryRequest, + compilation, + dependency: entryDependency, + clientEntryDependencyMap, + }) + + clientActionImports.forEach(([dep, names]) => + actionEntryImports.set(dep, names) + ) + } + + if (actionEntryImports.size > 0) { + addedClientActionEntryList.push( + this.injectActionEntry({ + compiler, + compilation, + actions: actionEntryImports, + entryName: name, + bundlePath: name, + fromClient: true, + }) + ) + } + }) + return Promise.all(addedClientActionEntryList) + }) + // After optimizing all the modules, we collect the CSS that are still used // by the certain chunk. compilation.hooks.afterOptimizeModules.tap(PLUGIN_NAME, () => { @@ -360,31 +387,8 @@ export class FlightClientEntryPlugin { }) }) - forEachEntryModule(({ name, entryModule }) => { - // To collect all CSS imports for a specific entry including the ones - // that are in the client graph, we need to store a map for client boundary - // dependencies. - const clientEntryDependencyMap: Record = {} - const entry = compilation.entries.get(name) - entry.includeDependencies.forEach((dep: any) => { - if ( - dep.request && - dep.request.startsWith('next-flight-client-entry-loader?') - ) { - const mod: webpack.NormalModule = - compilation.moduleGraph.getResolvedModule(dep) - - compilation.moduleGraph - .getOutgoingConnections(mod) - .forEach((connection: any) => { - if (connection.dependency) { - clientEntryDependencyMap[connection.dependency.request] = - connection.dependency - } - }) - } - }) - + forEachEntryModule(compilation, ({ name, entryModule }) => { + const clientEntryDependencyMap = collectClientEntryDependencyMap(name) const tracked = new Set() for (const connection of compilation.moduleGraph.getOutgoingConnections( entryModule @@ -476,6 +480,7 @@ export class FlightClientEntryPlugin { clientImports: ClientComponentImports cssImports: CssImports actionImports: [string, string[]][] + clientActionImports: [string, string[]][] } { /** * Keep track of checked modules to avoid infinite loops with recursive imports. @@ -483,6 +488,7 @@ export class FlightClientEntryPlugin { const visitedBySegment: { [segment: string]: Set } = {} const clientComponentImports: ClientComponentImports = [] const actionImports: [string, string[]][] = [] + const clientActionImports: [string, string[]][] = [] const CSSImports = new Set() const filterClientComponents = ( @@ -517,7 +523,11 @@ export class FlightClientEntryPlugin { const actions = getActions(mod) if (actions) { - actionImports.push([modRequest, actions]) + if (isClientComponent) { + clientActionImports.push([modRequest, actions]) + } else { + actionImports.push([modRequest, actions]) + } } if (isCSS) { @@ -564,9 +574,6 @@ export class FlightClientEntryPlugin { }) } - // Traverse the module graph to find all client components. - filterClientComponents(dependency, false) - // Don't traverse the module graph for the action loader. if (!/next-flight-action-entry-loader/.test(entryRequest)) { // Traverse the module graph to find all client components. @@ -581,6 +588,7 @@ export class FlightClientEntryPlugin { } : {}, actionImports, + clientActionImports, } } @@ -695,12 +703,14 @@ export class FlightClientEntryPlugin { actions, entryName, bundlePath, + fromClient, }: { compiler: webpack.Compiler compilation: webpack.Compilation actions: Map entryName: string bundlePath: string + fromClient?: boolean }) { const actionsArray = Array.from(actions.entries()) const actionLoader = `next-flight-action-entry-loader?${stringify({ @@ -734,7 +744,7 @@ export class FlightClientEntryPlugin { actionEntryDep, { name: entryName, - layer: WEBPACK_LAYERS.server, + layer: fromClient ? WEBPACK_LAYERS.action : WEBPACK_LAYERS.server, } ) } diff --git a/packages/next/src/build/webpack/utils.ts b/packages/next/src/build/webpack/utils.ts index e84b86ed5dd140..514ce87c99224c 100644 --- a/packages/next/src/build/webpack/utils.ts +++ b/packages/next/src/build/webpack/utils.ts @@ -1,4 +1,5 @@ import { webpack } from 'next/dist/compiled/webpack/webpack' +import { isAppRouteRoute } from '../../lib/is-app-route-route' export function traverseModules( compilation: webpack.Compilation, @@ -27,3 +28,48 @@ export function traverseModules( }) }) } + +// Loop over all the entry modules. +export function forEachEntryModule( + compilation: any, + callback: ({ name, entryModule }: { name: string; entryModule: any }) => void +) { + for (const [name, entry] of compilation.entries.entries()) { + // Skip for entries under pages/ + if ( + name.startsWith('pages/') || + // Skip for route.js entries + (name.startsWith('app/') && isAppRouteRoute(name)) + ) { + continue + } + + // Check if the page entry is a server component or not. + const entryDependency: webpack.NormalModule | undefined = + entry.dependencies?.[0] + // Ensure only next-app-loader entries are handled. + if (!entryDependency || !entryDependency.request) continue + + const request = entryDependency.request + + if ( + !request.startsWith('next-edge-ssr-loader?') && + !request.startsWith('next-app-loader?') + ) + continue + + let entryModule: webpack.NormalModule = + compilation.moduleGraph.getResolvedModule(entryDependency) + + if (request.startsWith('next-edge-ssr-loader?')) { + entryModule.dependencies.forEach((dependency) => { + const modRequest: string | undefined = (dependency as any).request + if (modRequest?.includes('next-app-loader')) { + entryModule = compilation.moduleGraph.getResolvedModule(dependency) + } + }) + } + + callback({ name, entryModule }) + } +} diff --git a/packages/next/src/client/app-call-server.ts b/packages/next/src/client/app-call-server.ts new file mode 100644 index 00000000000000..890115334d7632 --- /dev/null +++ b/packages/next/src/client/app-call-server.ts @@ -0,0 +1,18 @@ +export async function callServer(id: string, args: any[]) { + const actionId = id + + // Fetching the current url with the action header. + // TODO: Refactor this to look up from a manifest. + const res = await fetch('', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Next-Action': actionId, + }, + body: JSON.stringify({ + bound: args, + }), + }) + + return (await res.json())[0] +} diff --git a/packages/next/src/client/app-index.tsx b/packages/next/src/client/app-index.tsx index be8fc4f04f4396..346210335b52c5 100644 --- a/packages/next/src/client/app-index.tsx +++ b/packages/next/src/client/app-index.tsx @@ -8,6 +8,7 @@ import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-we import { HeadManagerContext } from '../shared/lib/head-manager-context' import { GlobalLayoutRouterContext } from '../shared/lib/app-router-context' import onRecoverableError from './on-recoverable-error' +import { callServer } from './app-call-server' /// @@ -151,24 +152,7 @@ function useInitialServerResponse(cacheKey: string): Promise { }) const newResponse = createFromReadableStream(readable, { - async callServer(id: string, args: any[]) { - const actionId = id - - // Fetching the current url with the action header. - // TODO: Refactor this to look up from a manifest. - const res = await fetch('', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Next-Action': actionId, - }, - body: JSON.stringify({ - bound: args, - }), - }) - - return (await res.json())[0] - }, + callServer, }) rscCache.set(cacheKey, newResponse) diff --git a/packages/next/src/lib/constants.ts b/packages/next/src/lib/constants.ts index 30ea3f162f6840..f16ccb893b47e6 100644 --- a/packages/next/src/lib/constants.ts +++ b/packages/next/src/lib/constants.ts @@ -80,6 +80,7 @@ export const WEBPACK_LAYERS = { shared: 'sc_shared', server: 'sc_server', client: 'sc_client', + action: 'sc_action', api: 'api', middleware: 'middleware', edgeAsset: 'edge-asset', diff --git a/test/development/acceptance-app/__snapshots__/ReactRefreshLogBox-builtins.test.ts.snap b/test/development/acceptance-app/__snapshots__/ReactRefreshLogBox-builtins.test.ts.snap index 9ba9510722cd40..ee19fa4f4749cc 100644 --- a/test/development/acceptance-app/__snapshots__/ReactRefreshLogBox-builtins.test.ts.snap +++ b/test/development/acceptance-app/__snapshots__/ReactRefreshLogBox-builtins.test.ts.snap @@ -41,7 +41,7 @@ https://nextjs.org/docs/messages/module-not-found" `; exports[`ReactRefreshLogBox app default Node.js builtins 1`] = ` -"./node_modules/my-package/index.js:2:0 +"./node_modules/my-package/index.js:2:22 Module not found: Can't resolve 'dns' https://nextjs.org/docs/messages/module-not-found diff --git a/test/e2e/app-dir/actions/app-action.test.ts b/test/e2e/app-dir/actions/app-action.test.ts index fc534138ddf3e2..d85a78e295ddcd 100644 --- a/test/e2e/app-dir/actions/app-action.test.ts +++ b/test/e2e/app-dir/actions/app-action.test.ts @@ -64,5 +64,24 @@ createNextDescribe( return browser.elementByCss('h1').text() }, 'my-not-found') }) + + it('should support importing actions in client components', async () => { + const browser = await next.browser('/client') + + const cnt = await browser.elementByCss('h1').text() + expect(cnt).toBe('0') + + await browser.elementByCss('#inc').click() + await check(() => browser.elementByCss('h1').text(), '1') + + await browser.elementByCss('#inc').click() + await check(() => browser.elementByCss('h1').text(), '2') + + await browser.elementByCss('#double').click() + await check(() => browser.elementByCss('h1').text(), '4') + + await browser.elementByCss('#dec').click() + await check(() => browser.elementByCss('h1').text(), '3') + }) } ) diff --git a/test/e2e/app-dir/actions/app/client/actions.js b/test/e2e/app-dir/actions/app/client/actions.js new file mode 100644 index 00000000000000..5357627442e512 --- /dev/null +++ b/test/e2e/app-dir/actions/app/client/actions.js @@ -0,0 +1,13 @@ +'use server' + +export async function inc(value) { + return value + 1 +} + +export async function dec(value) { + return value - 1 +} + +export default async function (value) { + return value * 2 +} diff --git a/test/e2e/app-dir/actions/app/client/page.js b/test/e2e/app-dir/actions/app/client/page.js new file mode 100644 index 00000000000000..b39f8181421f18 --- /dev/null +++ b/test/e2e/app-dir/actions/app/client/page.js @@ -0,0 +1,42 @@ +'use client' + +import { useState } from 'react' + +import double, { inc, dec } from './actions' + +export default function Counter() { + const [count, setCount] = useState(0) + + return ( +
+

{count}

+ + + +
+ ) +}