diff --git a/packages/next-swc/crates/core/src/lib.rs b/packages/next-swc/crates/core/src/lib.rs index c3512fde7273d..56385f74b737b 100644 --- a/packages/next-swc/crates/core/src/lib.rs +++ b/packages/next-swc/crates/core/src/lib.rs @@ -230,7 +230,7 @@ where Some(config) if config.truthy() => match config { // Always enable the Server Components mode for both // server and client layers. - react_server_components::Config::WithOptions(_) => true, + react_server_components::Config::WithOptions(config) => config.is_react_server_layer, _ => false, }, _ => false, diff --git a/packages/next-swc/crates/core/tests/fixture.rs b/packages/next-swc/crates/core/tests/fixture.rs index b856a69ca33c0..47d29fd2a29f1 100644 --- a/packages/next-swc/crates/core/tests/fixture.rs +++ b/packages/next-swc/crates/core/tests/fixture.rs @@ -112,7 +112,11 @@ fn next_dynamic_fixture(input: PathBuf) { fn app_dir_next_dynamic_fixture(input: PathBuf) { let output_dev = input.parent().unwrap().join("output-dev.js"); let output_prod = input.parent().unwrap().join("output-prod.js"); - let output_server = input.parent().unwrap().join("output-server.js"); + let output_server: PathBuf = input.parent().unwrap().join("output-server.js"); + let output_server_client_layer = input + .parent() + .unwrap() + .join("output-server-client-layer.js"); test_fixture( syntax(), &|_tr| { @@ -161,6 +165,22 @@ fn app_dir_next_dynamic_fixture(input: PathBuf) { &output_server, Default::default(), ); + test_fixture( + syntax(), + &|_tr| { + next_dynamic( + false, + true, + false, + NextDynamicMode::Webpack, + FileName::Real(PathBuf::from("/some-project/src/some-file.js")), + Some("/some-project/src".into()), + ) + }, + &input, + &output_server_client_layer, + Default::default(), + ); } #[fixture("tests/fixture/ssg/**/input.js")] diff --git a/packages/next-swc/crates/core/tests/fixture/next-dynamic-app-dir/no-ssr/output-server-client-layer.js b/packages/next-swc/crates/core/tests/fixture/next-dynamic-app-dir/no-ssr/output-server-client-layer.js new file mode 100644 index 0000000000000..682d5149babee --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/next-dynamic-app-dir/no-ssr/output-server-client-layer.js @@ -0,0 +1,11 @@ +import dynamic from 'next/dynamic'; +export const NextDynamicNoSSRServerComponent = dynamic(async ()=>{ + typeof require.resolveWeak !== "undefined" && require.resolveWeak("../text-dynamic-no-ssr-server"); +}, { + loadableGenerated: { + modules: [ + "some-file.js -> " + "../text-dynamic-no-ssr-server" + ] + }, + ssr: false +}); diff --git a/packages/next-swc/crates/core/tests/fixture/next-dynamic/issue-48098/output-server.js b/packages/next-swc/crates/core/tests/fixture/next-dynamic/issue-48098/output-server.js index 2954baa41658f..682d5149babee 100644 --- a/packages/next-swc/crates/core/tests/fixture/next-dynamic/issue-48098/output-server.js +++ b/packages/next-swc/crates/core/tests/fixture/next-dynamic/issue-48098/output-server.js @@ -1,5 +1,7 @@ import dynamic from 'next/dynamic'; -export const NextDynamicNoSSRServerComponent = dynamic(null, { +export const NextDynamicNoSSRServerComponent = dynamic(async ()=>{ + typeof require.resolveWeak !== "undefined" && require.resolveWeak("../text-dynamic-no-ssr-server"); +}, { loadableGenerated: { modules: [ "some-file.js -> " + "../text-dynamic-no-ssr-server" diff --git a/packages/next-swc/crates/core/tests/fixture/next-dynamic/with-options/output-server.js b/packages/next-swc/crates/core/tests/fixture/next-dynamic/with-options/output-server.js index dba5c9f3374d2..adcbd42e707eb 100644 --- a/packages/next-swc/crates/core/tests/fixture/next-dynamic/with-options/output-server.js +++ b/packages/next-swc/crates/core/tests/fixture/next-dynamic/with-options/output-server.js @@ -7,7 +7,9 @@ const DynamicComponentWithCustomLoading = dynamic(()=>import('../components/hell }, loading: ()=>

...

}); -const DynamicClientOnlyComponent = dynamic(null, { +const DynamicClientOnlyComponent = dynamic(async ()=>{ + typeof require.resolveWeak !== "undefined" && require.resolveWeak("../components/hello"); +}, { loadableGenerated: { modules: [ "some-file.js -> " + "../components/hello" @@ -15,7 +17,9 @@ const DynamicClientOnlyComponent = dynamic(null, { }, ssr: false }); -const DynamicClientOnlyComponentWithSuspense = dynamic(()=>import('../components/hello'), { +const DynamicClientOnlyComponentWithSuspense = dynamic(async ()=>{ + typeof require.resolveWeak !== "undefined" && require.resolveWeak("../components/hello"); +}, { loadableGenerated: { modules: [ "some-file.js -> " + "../components/hello" diff --git a/packages/next-swc/crates/core/tests/fixture/next-dynamic/wrapped-import/output-server.js b/packages/next-swc/crates/core/tests/fixture/next-dynamic/wrapped-import/output-server.js index df6e0e69d6ccc..30646f9818661 100644 --- a/packages/next-swc/crates/core/tests/fixture/next-dynamic/wrapped-import/output-server.js +++ b/packages/next-swc/crates/core/tests/fixture/next-dynamic/wrapped-import/output-server.js @@ -1,5 +1,7 @@ import dynamic from 'next/dynamic'; -const DynamicComponent = dynamic(null, { +const DynamicComponent = dynamic(async ()=>{ + typeof require.resolveWeak !== "undefined" && require.resolveWeak("./components/hello"); +}, { loadableGenerated: { modules: [ "some-file.js -> " + "./components/hello" diff --git a/packages/next-swc/crates/next-transform-dynamic/src/lib.rs b/packages/next-swc/crates/next-transform-dynamic/src/lib.rs index 5e58697107931..0505ff8e08de3 100644 --- a/packages/next-swc/crates/next-transform-dynamic/src/lib.rs +++ b/packages/next-swc/crates/next-transform-dynamic/src/lib.rs @@ -8,12 +8,12 @@ use swc_core::{ common::{errors::HANDLER, FileName, Span, DUMMY_SP}, ecma::{ ast::{ - ArrayLit, ArrowExpr, BlockStmtOrExpr, Bool, CallExpr, Callee, Expr, ExprOrSpread, - ExprStmt, Id, Ident, ImportDecl, ImportDefaultSpecifier, ImportNamedSpecifier, - ImportSpecifier, KeyValueProp, Lit, ModuleDecl, ModuleItem, Null, ObjectLit, Prop, - PropName, PropOrSpread, Stmt, Str, Tpl, + op, ArrayLit, ArrowExpr, BinExpr, BlockStmt, BlockStmtOrExpr, Bool, CallExpr, Callee, + Expr, ExprOrSpread, ExprStmt, Id, Ident, ImportDecl, ImportDefaultSpecifier, + ImportNamedSpecifier, ImportSpecifier, KeyValueProp, Lit, ModuleDecl, ModuleItem, + ObjectLit, Prop, PropName, PropOrSpread, Stmt, Str, Tpl, UnaryExpr, UnaryOp, }, - utils::{private_ident, ExprFactory}, + utils::{private_ident, quote_ident, ExprFactory}, visit::{Fold, FoldWith}, }, quote, @@ -236,12 +236,12 @@ impl Fold for NextDynamicPatcher { rel_filename(self.pages_dir.as_deref(), &self.filename) ) .into(), - right: Expr = dynamically_imported_specifier.into(), + right: Expr = dynamically_imported_specifier.clone().into(), )) } else { webpack_options(quote!( "require.resolveWeak($id)" as Expr, - id: Expr = dynamically_imported_specifier.into() + id: Expr = dynamically_imported_specifier.clone().into() )) } } @@ -259,7 +259,7 @@ impl Fold for NextDynamicPatcher { imports.push(TurbopackImport::DevelopmentTransition { id_ident: id_ident.clone(), chunks_ident: chunks_ident.clone(), - specifier: dynamically_imported_specifier, + specifier: dynamically_imported_specifier.clone(), }); // On the server, the key needs to be serialized because it @@ -280,7 +280,7 @@ impl Fold for NextDynamicPatcher { (true, false) => { imports.push(TurbopackImport::DevelopmentId { id_ident: id_ident.clone(), - specifier: dynamically_imported_specifier, + specifier: dynamically_imported_specifier.clone(), }); // On the client, we only need the target module ID, which @@ -365,11 +365,54 @@ impl Fold for NextDynamicPatcher { } } - // Also don't strip the `loader` argument for server components (both - // server/client layers), since they're aliased to a - // React.lazy implementation. - if has_ssr_false && self.is_server_compiler && !self.is_react_server_layer { - expr.args[0] = Lit::Null(Null { span: DUMMY_SP }).as_arg(); + if has_ssr_false + && self.is_server_compiler + && !self.is_react_server_layer + // Only use `require.resolveWebpack` to decouple modules for webpack, + // turbopack doesn't need this + && self.state == NextDynamicPatcherState::Webpack + { + // if it's server components SSR layer + // Transform 1st argument `expr.args[0]` aka the module loader from: + // dynamic(() => import('./client-mod'), { ssr: false }))` + // into: + // dynamic(async () => { + // require.resolveWeak('./client-mod') + // }, { ssr: false }))` + + let require_resolve_weak_expr = Expr::Call(CallExpr { + span: DUMMY_SP, + callee: quote_ident!("require.resolveWeak").as_callee(), + args: vec![ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: dynamically_imported_specifier.clone().into(), + raw: None, + }))), + }], + type_args: Default::default(), + }); + + let side_effect_free_loader_arg = Expr::Arrow(ArrowExpr { + span: DUMMY_SP, + params: vec![], + body: Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt { + span: DUMMY_SP, + stmts: vec![Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(exec_expr_when_resolve_weak_available( + &require_resolve_weak_expr, + )), + })], + })), + is_async: true, + is_generator: false, + type_params: None, + return_type: None, + }); + + expr.args[0] = side_effect_free_loader_arg.as_arg(); } let second_arg = ExprOrSpread { @@ -562,6 +605,37 @@ impl NextDynamicPatcher { } } +fn exec_expr_when_resolve_weak_available(expr: &Expr) -> Expr { + let undefined_str_literal = Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: "undefined".into(), + raw: None, + })); + + let typeof_expr = Expr::Unary(UnaryExpr { + span: DUMMY_SP, + op: UnaryOp::TypeOf, // 'typeof' operator + arg: Box::new(Expr::Ident(Ident { + span: DUMMY_SP, + sym: quote_ident!("require.resolveWeak").sym, + optional: false, + })), + }); + + // typeof require.resolveWeak !== 'undefined' && + Expr::Bin(BinExpr { + span: DUMMY_SP, + left: Box::new(Expr::Bin(BinExpr { + span: DUMMY_SP, + op: op!("!=="), + left: Box::new(typeof_expr), + right: Box::new(undefined_str_literal), + })), + op: op!("&&"), + right: Box::new(expr.clone()), + }) +} + fn rel_filename(base: Option<&Path>, file: &FileName) -> String { let base = match base { Some(v) => v, diff --git a/test/e2e/app-dir/actions/app-action.test.ts b/test/e2e/app-dir/actions/app-action.test.ts index ee94f02d54c3b..90423006d4997 100644 --- a/test/e2e/app-dir/actions/app-action.test.ts +++ b/test/e2e/app-dir/actions/app-action.test.ts @@ -323,6 +323,20 @@ createNextDescribe( }, '1') }) + it('should support next/dynamic with ssr: false (edge)', async () => { + const browser = await next.browser('/dynamic-csr/edge') + + await check(() => { + return browser.elementByCss('button').text() + }, '0') + + await browser.elementByCss('button').click() + + await check(() => { + return browser.elementByCss('button').text() + }, '1') + }) + it('should only submit action once when resubmitting an action after navigation', async () => { let requestCount = 0 diff --git a/test/e2e/app-dir/actions/app/dynamic-csr/edge/page.js b/test/e2e/app-dir/actions/app/dynamic-csr/edge/page.js new file mode 100644 index 0000000000000..cec9e7d464f81 --- /dev/null +++ b/test/e2e/app-dir/actions/app/dynamic-csr/edge/page.js @@ -0,0 +1,20 @@ +'use client' + +import dynamic from 'next/dynamic' + +const DynamicComponent = dynamic( + () => import('../csr').then((mod) => mod.CSR), + { + ssr: false, + } +) + +export default function Client() { + return ( +
+ +
+ ) +} + +export const runtime = 'edge' diff --git a/test/e2e/app-dir/dynamic/app/chunk-loading/page.js b/test/e2e/app-dir/dynamic/app/chunk-loading/page.js index a8d54a91b6365..5e6d9c4a4b0d9 100644 --- a/test/e2e/app-dir/dynamic/app/chunk-loading/page.js +++ b/test/e2e/app-dir/dynamic/app/chunk-loading/page.js @@ -3,9 +3,11 @@ 'use client' +function noop() {} + export default function Page() { import('./comp').then((m) => { - console.log(m) + noop(m) }) return null } diff --git a/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/client-edge/page.js b/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/client-edge/page.js new file mode 100644 index 0000000000000..f81984639c642 --- /dev/null +++ b/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/client-edge/page.js @@ -0,0 +1,5 @@ +'use client' + +export { default } from '../dynamic-import' + +export const runtime = 'edge' diff --git a/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/client/page.js b/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/client/page.js new file mode 100644 index 0000000000000..1d236870d8aa8 --- /dev/null +++ b/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/client/page.js @@ -0,0 +1,3 @@ +'use client' + +export { default } from '../dynamic-import' diff --git a/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/dynamic-import.js b/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/dynamic-import.js new file mode 100644 index 0000000000000..5f9fe4abd3471 --- /dev/null +++ b/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/dynamic-import.js @@ -0,0 +1,14 @@ +import dynamic from 'next/dynamic' + +const DynamicSSRFalse = dynamic(() => import('./ssr-false-module'), { + ssr: false, +}) + +export default function page() { + return ( +
+ +

dynamic-mixed-ssr-false

+
+ ) +} diff --git a/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/ssr-false-module/index.js b/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/ssr-false-module/index.js new file mode 100644 index 0000000000000..4a09a28c85dcc --- /dev/null +++ b/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/ssr-false-module/index.js @@ -0,0 +1,11 @@ +import Client from './ssr-false-client' +import Server from './ssr-false-server' + +export default function Comp() { + return ( + <> + + + + ) +} diff --git a/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/ssr-false-module/ssr-false-client.js b/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/ssr-false-module/ssr-false-client.js new file mode 100644 index 0000000000000..fb100470368df --- /dev/null +++ b/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/ssr-false-module/ssr-false-client.js @@ -0,0 +1,5 @@ +'use client' + +export default function Comp() { + return

ssr-false-client-module-text

+} diff --git a/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/ssr-false-module/ssr-false-server.js b/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/ssr-false-module/ssr-false-server.js new file mode 100644 index 0000000000000..c2335d5d176b2 --- /dev/null +++ b/test/e2e/app-dir/dynamic/app/dynamic-mixed-ssr-false/ssr-false-module/ssr-false-server.js @@ -0,0 +1,3 @@ +export default function Comp() { + return

ssr-false-server-module-text

+} diff --git a/test/e2e/app-dir/dynamic/dynamic.test.ts b/test/e2e/app-dir/dynamic/dynamic.test.ts index 11cb06e9515a5..0e76cc5a5384b 100644 --- a/test/e2e/app-dir/dynamic/dynamic.test.ts +++ b/test/e2e/app-dir/dynamic/dynamic.test.ts @@ -6,7 +6,7 @@ createNextDescribe( files: __dirname, skipDeployment: true, }, - ({ next }) => { + ({ next, isNextStart }) => { it('should handle ssr: false in pages when appDir is enabled', async () => { const $ = await next.render$('/legacy/no-ssr') expect($.html()).not.toContain('navigator') @@ -57,5 +57,65 @@ createNextDescribe( const $ = await next.render$('/chunk-loading/server') expect($('h1').text()).toBe('hello') }) + + describe('no SSR', () => { + it('should not render client component imported through ssr: false in client components in edge runtime', async () => { + // noSSR should not show up in html + const $ = await next.render$('/dynamic-mixed-ssr-false/client-edge') + expect($('#server-false-server-module')).not.toContain( + 'ssr-false-server-module-text' + ) + expect($('#server-false-client-module')).not.toContain( + 'ssr-false-client-module-text' + ) + // noSSR should not show up in browser + const browser = await next.browser( + '/dynamic-mixed-ssr-false/client-edge' + ) + expect( + await browser.elementByCss('#ssr-false-server-module').text() + ).toBe('ssr-false-server-module-text') + expect( + await browser.elementByCss('#ssr-false-client-module').text() + ).toBe('ssr-false-client-module-text') + + // in the server bundle should not contain client component imported through ssr: false + if (isNextStart) { + const chunkPath = + '.next/server/app/dynamic-mixed-ssr-false/client-edge/page.js' + const edgeServerChunk = await next.readFile(chunkPath) + + expect(edgeServerChunk).not.toContain('ssr-false-client-module-text') + } + }) + + it('should not render client component imported through ssr: false in client components', async () => { + // noSSR should not show up in html + const $ = await next.render$('/dynamic-mixed-ssr-false/client') + expect($('#client-false-server-module')).not.toContain( + 'ssr-false-server-module-text' + ) + expect($('#client-false-client-module')).not.toContain( + 'ssr-false-client-module-text' + ) + // noSSR should not show up in browser + const browser = await next.browser('/dynamic-mixed-ssr-false/client') + expect( + await browser.elementByCss('#ssr-false-server-module').text() + ).toBe('ssr-false-server-module-text') + expect( + await browser.elementByCss('#ssr-false-client-module').text() + ).toBe('ssr-false-client-module-text') + + // in the server bundle should not contain both server and client component imported through ssr: false + if (isNextStart) { + const pageServerChunk = await next.readFile( + '.next/server/app/dynamic-mixed-ssr-false/client/page.js' + ) + expect(pageServerChunk).not.toContain('ssr-false-server-module-text') + expect(pageServerChunk).not.toContain('ssr-false-client-module-text') + } + }) + }) } )