diff --git a/crates/next-custom-transforms/src/transforms/react_server_components.rs b/crates/next-custom-transforms/src/transforms/react_server_components.rs index 4d5288503c89d..da68d741af571 100644 --- a/crates/next-custom-transforms/src/transforms/react_server_components.rs +++ b/crates/next-custom-transforms/src/transforms/react_server_components.rs @@ -21,7 +21,7 @@ use swc_core::{ }, }; -use super::cjs_finder::contains_cjs; +use super::{cjs_finder::contains_cjs, import_analyzer::ImportMap}; #[derive(Clone, Debug, Deserialize)] #[serde(untagged)] @@ -76,6 +76,7 @@ enum RSCErrorKind { NextRscErrConflictMetadataExport(Span), NextRscErrInvalidApi((String, Span)), NextRscErrDeprecatedApi((String, String, Span)), + NextSsrDynamicFalseNotAllowed(Span), } impl VisitMut for ReactServerComponents { @@ -301,6 +302,11 @@ fn report_error(app_dir: &Option, filepath: &str, error_kind: RSCErrorK ), _ => (format!("\"{source}\" is deprecated."), span), }, + RSCErrorKind::NextSsrDynamicFalseNotAllowed(span) => ( + "`ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a client component." + .to_string(), + span, + ), }; HANDLER.with(|handler| handler.struct_span_err(span, msg.as_str()).emit()) @@ -502,6 +508,7 @@ struct ReactServerComponentValidator { invalid_client_imports: Vec, invalid_client_lib_apis_mapping: HashMap<&'static str, Vec<&'static str>>, pub directive_import_collection: Option<(bool, bool, RcVec, RcVec)>, + imports: ImportMap, } // A type to workaround a clippy warning. @@ -576,6 +583,7 @@ impl ReactServerComponentValidator { invalid_client_imports: vec![JsWord::from("server-only"), JsWord::from("next/headers")], invalid_client_lib_apis_mapping: [("next/server", vec!["unstable_after"])].into(), + imports: ImportMap::default(), } } @@ -584,6 +592,13 @@ impl ReactServerComponentValidator { RE.is_match(filepath) } + fn is_callee_next_dynamic(&self, callee: &Callee) -> bool { + match callee { + Callee::Expr(expr) => self.imports.is_import(expr, "next/dynamic", "default"), + _ => false, + } + } + // Asserts the server lib apis // e.g. // assert_invalid_server_lib_apis("react", import) @@ -817,6 +832,44 @@ impl ReactServerComponentValidator { } } } + + /// ``` + /// import dynamic from 'next/dynamic' + /// + /// dynamic(() => import(...)) // ✅ + /// dynamic(() => import(...), { ssr: true }) // ✅ + /// dynamic(() => import(...), { ssr: false }) // ❌ + /// ``` + + fn check_for_next_ssr_false(&self, node: &CallExpr) -> Option<()> { + if !self.is_callee_next_dynamic(&node.callee) { + return None; + } + + let ssr_arg = node.args.get(1)?; + let obj = ssr_arg.expr.as_object()?; + + for prop in obj.props.iter().filter_map(|v| v.as_prop()?.as_key_value()) { + let is_ssr = match &prop.key { + PropName::Ident(IdentName { sym, .. }) => sym == "ssr", + PropName::Str(s) => s.value == "ssr", + _ => false, + }; + + if is_ssr { + let value = prop.value.as_lit()?; + if let Lit::Bool(Bool { value: false, .. }) = value { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextSsrDynamicFalseNotAllowed(node.span), + ); + } + } + } + + None + } } impl Visit for ReactServerComponentValidator { @@ -830,7 +883,17 @@ impl Visit for ReactServerComponentValidator { } } + fn visit_call_expr(&mut self, node: &CallExpr) { + node.visit_children_with(self); + + if self.is_react_server_layer { + self.check_for_next_ssr_false(node); + } + } + fn visit_module(&mut self, module: &Module) { + self.imports = ImportMap::analyze(module); + let (is_client_entry, is_action_file, imports, export_names) = collect_top_level_directives_and_imports(&self.app_dir, &self.filepath, module); let imports = Rc::new(imports); diff --git a/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/input.js b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/input.js new file mode 100644 index 0000000000000..fcf24da9a9b05 --- /dev/null +++ b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/input.js @@ -0,0 +1,5 @@ +import dynamic from 'next/dynamic' + +export default function () { + return dynamic(() => import('client-only'), { ssr: false }) +} diff --git a/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/output.js b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/output.js new file mode 100644 index 0000000000000..9b8e0b5585152 --- /dev/null +++ b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/output.js @@ -0,0 +1,6 @@ +import dynamic from 'next/dynamic'; +export default function() { + return dynamic(()=>import('client-only'), { + ssr: false + }); +} diff --git a/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/output.stderr b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/output.stderr new file mode 100644 index 0000000000000..7df6cc022eb2a --- /dev/null +++ b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-ssr-false/output.stderr @@ -0,0 +1,7 @@ + x `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a client component. + ,-[input.js:4:1] + 3 | export default function () { + 4 | return dynamic(() => import('client-only'), { ssr: false }) + : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 5 | } + `---- diff --git a/docs/02-app/01-building-your-application/06-optimizing/07-lazy-loading.mdx b/docs/02-app/01-building-your-application/06-optimizing/07-lazy-loading.mdx index ccde08193c244..5cf94abb99f03 100644 --- a/docs/02-app/01-building-your-application/06-optimizing/07-lazy-loading.mdx +++ b/docs/02-app/01-building-your-application/06-optimizing/07-lazy-loading.mdx @@ -72,8 +72,6 @@ const ComponentC = dynamic(() => import('../components/C'), { ssr: false }) If you dynamically import a Server Component, only the Client Components that are children of the Server Component will be lazy-loaded - not the Server Component itself. It will also help preload the static assets such as CSS when you're using it in Server Components. -> **Note:** `ssr: false` option is not supported in Server Components. - ```jsx filename="app/page.js" import dynamic from 'next/dynamic' @@ -89,6 +87,9 @@ export default function ServerComponentExample() { } ``` +> **Note:** `ssr: false` option is not supported in Server Components. You will see an error if you try to use it in Server Components. +> `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a client component. + ### Loading External Libraries External libraries can be loaded on demand using the [`import()`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/import) function. This example uses the external library `fuse.js` for fuzzy search. The module is only loaded on the client after the user types in the search input. diff --git a/test/development/app-dir/server-component-next-dynamic-ssr-false/app/client.js b/test/development/app-dir/server-component-next-dynamic-ssr-false/app/client.js new file mode 100644 index 0000000000000..88db3f6a593bd --- /dev/null +++ b/test/development/app-dir/server-component-next-dynamic-ssr-false/app/client.js @@ -0,0 +1,5 @@ +'use client' + +export default function Client() { + return 'client' +} diff --git a/test/development/app-dir/server-component-next-dynamic-ssr-false/app/layout.js b/test/development/app-dir/server-component-next-dynamic-ssr-false/app/layout.js new file mode 100644 index 0000000000000..a3a86a5ca1e12 --- /dev/null +++ b/test/development/app-dir/server-component-next-dynamic-ssr-false/app/layout.js @@ -0,0 +1,7 @@ +export default function Root({ children }) { + return ( + + {children} + + ) +} diff --git a/test/development/app-dir/server-component-next-dynamic-ssr-false/app/page.js b/test/development/app-dir/server-component-next-dynamic-ssr-false/app/page.js new file mode 100644 index 0000000000000..025651d8118b6 --- /dev/null +++ b/test/development/app-dir/server-component-next-dynamic-ssr-false/app/page.js @@ -0,0 +1,7 @@ +import dynamic from 'next/dynamic' + +const DynamicClient = dynamic(() => import('./client'), { ssr: false }) + +export default function Page() { + return +} diff --git a/test/development/app-dir/server-component-next-dynamic-ssr-false/server-component-next-dynamic-ssr-false.test.ts b/test/development/app-dir/server-component-next-dynamic-ssr-false/server-component-next-dynamic-ssr-false.test.ts new file mode 100644 index 0000000000000..39418cc33f11e --- /dev/null +++ b/test/development/app-dir/server-component-next-dynamic-ssr-false/server-component-next-dynamic-ssr-false.test.ts @@ -0,0 +1,52 @@ +import { nextTestSetup } from 'e2e-utils' +import { + assertHasRedbox, + getRedboxDescription, + getRedboxSource, +} from 'next-test-utils' + +describe('app-dir - server-component-next-dynamic-ssr-false', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('should error when use dynamic ssr:false in server component', async () => { + const browser = await next.browser('/') + await assertHasRedbox(browser) + const redbox = { + description: await getRedboxDescription(browser), + source: await getRedboxSource(browser), + } + + expect(redbox.description).toBe('Failed to compile') + if (process.env.TURBOPACK) { + expect(redbox.source).toMatchInlineSnapshot(` + "./app/page.js:3:23 + Ecmascript file had an error + 1 | import dynamic from 'next/dynamic' + 2 | + > 3 | const DynamicClient = dynamic(() => import('./client'), { ssr: false }) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 4 | + 5 | export default function Page() { + 6 | return + + \`ssr: false\` is not allowed with \`next/dynamic\` in Server Components. Please move it into a client component." + `) + } else { + expect(redbox.source).toMatchInlineSnapshot(` + "./app/page.js + Error: x \`ssr: false\` is not allowed with \`next/dynamic\` in Server Components. Please move it into a client component. + ,-[3:1] + 1 | import dynamic from 'next/dynamic' + 2 | + 3 | const DynamicClient = dynamic(() => import('./client'), { ssr: false }) + : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 4 | + 5 | export default function Page() { + 6 | return + \`----" + `) + } + }) +}) 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 index 4a09a28c85dcc..b083fd88d0c79 100644 --- 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 @@ -1,11 +1,11 @@ import Client from './ssr-false-client' -import Server from './ssr-false-server' +// import Server from './ssr-false-server' export default function Comp() { return ( <> - + {/* */} ) } diff --git a/test/e2e/app-dir/dynamic/app/dynamic/async-client/page.js b/test/e2e/app-dir/dynamic/app/dynamic/async-client/page.js index 23ff43d931c82..03fb5bb999561 100644 --- a/test/e2e/app-dir/dynamic/app/dynamic/async-client/page.js +++ b/test/e2e/app-dir/dynamic/app/dynamic/async-client/page.js @@ -1,3 +1,5 @@ +'use client' + import dynamic from 'next/dynamic' const Client1 = dynamic(() => import('./client')) diff --git a/test/e2e/app-dir/dynamic/app/dynamic/dynamic-imports/dynamic-server.js b/test/e2e/app-dir/dynamic/app/dynamic/dynamic-imports/dynamic-server.js index 1562ee80f03f1..27597aaa1287b 100644 --- a/test/e2e/app-dir/dynamic/app/dynamic/dynamic-imports/dynamic-server.js +++ b/test/e2e/app-dir/dynamic/app/dynamic/dynamic-imports/dynamic-server.js @@ -3,12 +3,12 @@ import dynamic from 'next/dynamic' export const NextDynamicServerComponent = dynamic( () => import('../text-dynamic-server') ) -export const NextDynamicNoSSRServerComponent = dynamic( - () => import('../text-dynamic-no-ssr-server'), - { - ssr: false, - } -) +// export const NextDynamicNoSSRServerComponent = dynamic( +// () => import('../text-dynamic-no-ssr-server'), +// { +// ssr: false, +// } +// ) export const NextDynamicServerImportClientComponent = dynamic( () => import('../text-dynamic-server-import-client') ) diff --git a/test/e2e/app-dir/dynamic/app/dynamic/page.js b/test/e2e/app-dir/dynamic/app/dynamic/page.js index bafe563147601..004768e7f5489 100644 --- a/test/e2e/app-dir/dynamic/app/dynamic/page.js +++ b/test/e2e/app-dir/dynamic/app/dynamic/page.js @@ -3,7 +3,7 @@ import { NextDynamicClientComponent } from './dynamic-imports/dynamic-client' import { NextDynamicServerComponent, NextDynamicServerImportClientComponent, - NextDynamicNoSSRServerComponent, + // NextDynamicNoSSRServerComponent, } from './dynamic-imports/dynamic-server' export default function page() { @@ -13,7 +13,7 @@ export default function page() { - + {/* */} ) } diff --git a/test/e2e/app-dir/dynamic/app/dynamic/text-dynamic-no-ssr-server.js b/test/e2e/app-dir/dynamic/app/dynamic/text-dynamic-no-ssr-server.js index c54da3a133f71..7052e4bcb3588 100644 --- a/test/e2e/app-dir/dynamic/app/dynamic/text-dynamic-no-ssr-server.js +++ b/test/e2e/app-dir/dynamic/app/dynamic/text-dynamic-no-ssr-server.js @@ -1,12 +1,12 @@ -import TextClient from './text-client' +// import TextClient from './text-client' -export default function Dynamic() { - return ( - <> -

- next-dynamic dynamic no ssr on server -

- - - ) -} +// export default function Dynamic() { +// return ( +// <> +//

+// next-dynamic dynamic no ssr on server +//

+// +// +// ) +// } diff --git a/test/e2e/app-dir/dynamic/dynamic.test.ts b/test/e2e/app-dir/dynamic/dynamic.test.ts index df482322a1cfe..4c47283646bdf 100644 --- a/test/e2e/app-dir/dynamic/dynamic.test.ts +++ b/test/e2e/app-dir/dynamic/dynamic.test.ts @@ -33,19 +33,10 @@ describe('app dir - next/dynamic', () => { expect(serverContent).toContain('next-dynamic dynamic on client') expect(serverContent).toContain('next-dynamic server import client') expect(serverContent).not.toContain('next-dynamic dynamic no ssr on client') - - expect(serverContent).not.toContain('next-dynamic dynamic no ssr on server') - - // client component under server component with ssr: false will not be rendered either in flight or SSR - expect($.html()).not.toContain('client component under sever no ssr') }) it('should handle next/dynamic in hydration correctly', async () => { - const selector = 'body div' const browser = await next.browser('/dynamic') - const clientContent = await browser.elementByCss(selector).text() - expect(clientContent).toContain('next-dynamic dynamic no ssr on server') - expect(clientContent).toContain('client component under sever no ssr') await browser.waitForElementByCss('#css-text-dynamic-no-ssr-client') expect( @@ -91,17 +82,11 @@ describe('app dir - next/dynamic', () => { 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') @@ -119,17 +104,11 @@ describe('app dir - next/dynamic', () => { 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') @@ -139,7 +118,6 @@ describe('app dir - next/dynamic', () => { 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') } }) diff --git a/test/e2e/app-dir/missing-suspense-with-csr-bailout/app/dynamic/page.js b/test/e2e/app-dir/missing-suspense-with-csr-bailout/app/dynamic/page.js index 07fbb97ec38bd..4717f0edd0f65 100644 --- a/test/e2e/app-dir/missing-suspense-with-csr-bailout/app/dynamic/page.js +++ b/test/e2e/app-dir/missing-suspense-with-csr-bailout/app/dynamic/page.js @@ -1,3 +1,5 @@ +'use client' + import dynamic from 'next/dynamic' const Dynamic = dynamic(() => import('./dynamic'), { diff --git a/test/integration/next-image-new/app-dir/app/dynamic-static-img/async-image.js b/test/integration/next-image-new/app-dir/app/dynamic-static-img/async-image.js new file mode 100644 index 0000000000000..6b804bb6ee217 --- /dev/null +++ b/test/integration/next-image-new/app-dir/app/dynamic-static-img/async-image.js @@ -0,0 +1,10 @@ +'use client' + +import dynamic from 'next/dynamic' + +export const DynamicStaticImg = dynamic( + () => import('../../components/static-img'), + { + ssr: false, + } +) diff --git a/test/integration/next-image-new/app-dir/app/dynamic-static-img/page.js b/test/integration/next-image-new/app-dir/app/dynamic-static-img/page.js index e867952a05924..008a887707fc9 100644 --- a/test/integration/next-image-new/app-dir/app/dynamic-static-img/page.js +++ b/test/integration/next-image-new/app-dir/app/dynamic-static-img/page.js @@ -1,8 +1,4 @@ -import dynamic from 'next/dynamic' - -const DynamicStaticImg = dynamic(() => import('../../components/static-img'), { - ssr: false, -}) +import { DynamicStaticImg } from './async-image' export default () => { return (