diff --git a/docs/advanced-features/dynamic-import.md b/docs/advanced-features/dynamic-import.md
index f6e53ff6e3e5..cf8b2a08c38b 100644
--- a/docs/advanced-features/dynamic-import.md
+++ b/docs/advanced-features/dynamic-import.md
@@ -179,3 +179,6 @@ function Home() {
export default Home
```
+
+If you're using `suspense: true`, `ssr` option will set to `true` to delegate rendering to React ``.
+It's similar to using `React.lazy`, but `next/dynamic` will support mode like SSG.
diff --git a/packages/next/shared/lib/dynamic.tsx b/packages/next/shared/lib/dynamic.tsx
index e67a174dff25..99248d3ee6f6 100644
--- a/packages/next/shared/lib/dynamic.tsx
+++ b/packages/next/shared/lib/dynamic.tsx
@@ -67,8 +67,6 @@ export function noSSR
(
)
}
-// function dynamic
(options: O):
-
export default function dynamic
(
dynamicOptions: DynamicOptions
| Loader
,
options?: DynamicOptions
@@ -115,36 +113,53 @@ export default function dynamic
(
loadableOptions = { ...loadableOptions, ...options }
if (!process.env.__NEXT_REACT_ROOT) {
+ if (
+ process.env.NODE_ENV !== 'production' &&
+ loadableOptions.suspense &&
+ !isServerSide
+ ) {
+ console.warn(
+ `Enable experimental.reactRoot or use React version above 18 to use suspense option`
+ )
+ }
loadableOptions.suspense = false
}
+ // If suspense is enabled, delegate rendering to suspense
+ if (loadableOptions.suspense) {
+ loadableOptions.ssr = true
+ }
- if (
- typeof loadableOptions.loader === 'function' &&
- loadableOptions.suspense
- ) {
+ const { suspense, ssr, loader, loadableGenerated } = loadableOptions
+ // client side rendering
+ const csr = ssr === false
+ delete loadableOptions.ssr
+ delete loadableOptions.loadableGenerated
+
+ if (typeof loadableOptions.loader === 'function' && suspense) {
+ // If `suspense = true` and `ssr = false`:
+ // render empty on server side;
+ // render lazy component on client side.
+ if (csr && isServerSide) {
+ return () => null
+ }
loadableOptions.loader = React.lazy(
- loadableOptions.loader as () => Promise<{
+ loader as () => Promise<{
default: React.ComponentType
}>
)
}
// coming from build/babel/plugins/react-loadable-plugin.js
- if (loadableOptions.loadableGenerated) {
+ if (loadableGenerated) {
loadableOptions = {
...loadableOptions,
- ...loadableOptions.loadableGenerated,
+ ...loadableGenerated,
}
- delete loadableOptions.loadableGenerated
}
// support for disabling server side rendering, eg: dynamic(import('../hello-world'), {ssr: false})
- if (typeof loadableOptions.ssr === 'boolean') {
- if (!loadableOptions.ssr) {
- delete loadableOptions.ssr
- return noSSR(loadableFn, loadableOptions)
- }
- delete loadableOptions.ssr
+ if (csr && !suspense) {
+ return noSSR(loadableFn, loadableOptions)
}
return loadableFn(loadableOptions)
diff --git a/packages/next/shared/lib/loadable.js b/packages/next/shared/lib/loadable.js
index 9c35103bf156..b1650baa4a76 100644
--- a/packages/next/shared/lib/loadable.js
+++ b/packages/next/shared/lib/loadable.js
@@ -112,6 +112,15 @@ function createLoadableComponent(loadFn, options) {
})
}
+ function useDynamicModules(modules) {
+ const context = React.useContext(LoadableContext)
+ if (context && Array.isArray(modules)) {
+ modules.forEach((moduleName) => {
+ context(moduleName)
+ })
+ }
+ }
+
function LoadableImpl(props, ref) {
init()
@@ -142,18 +151,9 @@ function createLoadableComponent(loadFn, options) {
}, [props, state])
}
- function useDynamicModules(modules) {
- const context = React.useContext(LoadableContext)
- if (context && Array.isArray(modules)) {
- modules.forEach((moduleName) => {
- context(moduleName)
- })
- }
- }
-
- function LazyImpl(props) {
+ function LazyImpl(props, ref) {
useDynamicModules(opts.modules)
- return React.createElement(opts.loader, props)
+ return React.createElement(opts.loader, { ...props, ref })
}
const LoadableComponent = opts.suspense ? LazyImpl : LoadableImpl
diff --git a/test/integration/react-18/prerelease/components/dynamic-suspense.js b/test/integration/react-18/prerelease/components/dynamic-suspense.js
index 2717ac14f6a8..94977959d2ad 100644
--- a/test/integration/react-18/prerelease/components/dynamic-suspense.js
+++ b/test/integration/react-18/prerelease/components/dynamic-suspense.js
@@ -1,12 +1,15 @@
import { Suspense } from 'react'
import dynamic from 'next/dynamic'
-const Hello = dynamic(() => import('./hello'), { suspense: true })
+const Hello = dynamic(() => import('./hello'), {
+ ssr: false,
+ suspense: false,
+})
-export default function DynamicSuspense({ suspended }) {
+export default function SuspenseNoSSR({ thrown }) {
return (
-
+
)
}
diff --git a/test/integration/react-18/prerelease/components/hello.js b/test/integration/react-18/prerelease/components/hello.js
index bb41750a2278..68f35cf0ebb7 100644
--- a/test/integration/react-18/prerelease/components/hello.js
+++ b/test/integration/react-18/prerelease/components/hello.js
@@ -1,8 +1,10 @@
+import React from 'react'
import ReactDOM from 'react-dom'
-export default function Hello({ suspended = false }) {
- if (suspended && typeof window !== 'undefined') {
- throw new Promise((resolve) => setTimeout(resolve, 1000))
+export default function Hello({ thrown = false }) {
+ // only throw on server side render
+ if (thrown && typeof window === 'undefined') {
+ throw new Promise((resolve) => setTimeout(resolve, 500))
}
return
hello {ReactDOM.version}
}
diff --git a/test/integration/react-18/prerelease/pages/suspense/content.js b/test/integration/react-18/prerelease/pages/suspense/content.js
deleted file mode 100644
index 0edcf903f825..000000000000
--- a/test/integration/react-18/prerelease/pages/suspense/content.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import DynamicSuspense from '../../components/dynamic-suspense'
-
-export default function Dynamic() {
- return
-}
diff --git a/test/integration/react-18/prerelease/pages/suspense/fallback.js b/test/integration/react-18/prerelease/pages/suspense/fallback.js
deleted file mode 100644
index a57439eb7d64..000000000000
--- a/test/integration/react-18/prerelease/pages/suspense/fallback.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import DynamicSuspense from '../../components/dynamic-suspense'
-
-export default function Dynamic() {
- return
-}
diff --git a/test/integration/react-18/prerelease/pages/suspense/no-thrown.js b/test/integration/react-18/prerelease/pages/suspense/no-thrown.js
new file mode 100644
index 000000000000..d2a0f043536f
--- /dev/null
+++ b/test/integration/react-18/prerelease/pages/suspense/no-thrown.js
@@ -0,0 +1,5 @@
+import DynamicSuspense from '../../components/dynamic-suspense'
+
+export default function NoThrown() {
+ return
+}
diff --git a/test/integration/react-18/prerelease/pages/suspense/thrown.js b/test/integration/react-18/prerelease/pages/suspense/thrown.js
new file mode 100644
index 000000000000..2b4ea326f3af
--- /dev/null
+++ b/test/integration/react-18/prerelease/pages/suspense/thrown.js
@@ -0,0 +1,5 @@
+import DynamicSuspense from '../../components/dynamic-suspense'
+
+export default function Thrown() {
+ return
+}
diff --git a/test/integration/react-18/test/dynamic.js b/test/integration/react-18/test/dynamic.js
index cffce6e4e3a0..e99e56178023 100644
--- a/test/integration/react-18/test/dynamic.js
+++ b/test/integration/react-18/test/dynamic.js
@@ -1,7 +1,31 @@
/* eslint-env jest */
+import { join } from 'path'
import webdriver from 'next-webdriver'
import cheerio from 'cheerio'
import { check } from 'next-test-utils'
+import { File } from 'next-test-utils'
+
+const appDir = join(__dirname, '../prerelease')
+const page = new File(join(appDir, 'components/dynamic-suspense.js'))
+
+function writeComponent({ ssr = false, suspense = false }) {
+ const content = `import { Suspense } from 'react'
+ import dynamic from 'next/dynamic'
+
+ const Hello = dynamic(() => import('./hello'), {
+ ssr: ${ssr},
+ suspense: ${suspense},
+ })
+
+ export default function SuspenseNoSSR({ thrown }) {
+ return (
+
+
+
+ )
+ }`
+ page.write(content)
+}
export default (context, render) => {
async function get$(path, query) {
@@ -10,8 +34,58 @@ export default (context, render) => {
}
describe('suspense:true option', () => {
- it('Should render the fallback on the server side', async () => {
- const $ = await get$('/suspense/fallback')
+ describe('promise is thrown on server side', () => {
+ beforeAll(() => {
+ writeComponent({ suspense: true })
+ })
+
+ it('should render the fallback on server side', async () => {
+ const $ = await get$('/suspense/thrown')
+ const html = $('body').html()
+ expect(html).toContain('loading')
+ expect(JSON.parse($('#__NEXT_DATA__').html()).dynamicIds).toContain(
+ './components/hello.js'
+ )
+ })
+
+ it('should hydrate suspenses on client side', async () => {
+ let browser
+ try {
+ browser = await webdriver(context.appPort, '/suspense/thrown')
+ await check(() => browser.elementByCss('body').text(), /hello/)
+ } finally {
+ if (browser) {
+ await browser.close()
+ }
+ }
+ })
+ })
+
+ describe('promise is not thrown on server side', () => {
+ it('should render fallback on server side', async () => {
+ const $ = await get$('/suspense/no-thrown')
+ const text = $('#__next').text()
+ expect(text).toBe('loading')
+ })
+
+ it('should hydrate on client side', async () => {
+ let browser
+ try {
+ browser = await webdriver(context.appPort, '/suspense/no-thrown')
+ await check(() => browser.elementByCss('body').text(), /hello 18/)
+ } finally {
+ if (browser) {
+ await browser.close()
+ }
+ }
+ })
+ })
+ })
+
+ describe('suspense:false option', () => {
+ it('should render the fallback on server side', async () => {
+ writeComponent({ suspense: false, ssr: false })
+ const $ = await get$('/suspense/thrown')
const html = $('body').html()
expect(html).toContain('loading')
expect(JSON.parse($('#__NEXT_DATA__').html()).dynamicIds).toContain(
@@ -19,11 +93,12 @@ export default (context, render) => {
)
})
- it('should render the component on client side', async () => {
+ it('should hydrate suspenses on client side with ssr disabled', async () => {
+ writeComponent({ suspense: false, ssr: false })
let browser
try {
- browser = await webdriver(context.appPort, '/suspense/fallback')
- await check(() => browser.elementByCss('body').text(), /hello/)
+ browser = await webdriver(context.appPort, '/suspense/no-thrown')
+ await check(() => browser.elementByCss('body').text(), /hello 18/)
} finally {
if (browser) {
await browser.close()
@@ -31,15 +106,4 @@ export default (context, render) => {
}
})
})
-
- describe('suspense:false option', () => {
- it('Should render the content on the server side', async () => {
- const $ = await get$('/suspense/content')
- const html = $('body').html()
- expect(html).toContain('hello')
- expect(JSON.parse($('#__NEXT_DATA__').html()).dynamicIds).toContain(
- './components/hello.js'
- )
- })
- })
}
diff --git a/test/integration/react-18/test/index.test.js b/test/integration/react-18/test/index.test.js
index a2346aa8a255..e2f94bddfea1 100644
--- a/test/integration/react-18/test/index.test.js
+++ b/test/integration/react-18/test/index.test.js
@@ -55,7 +55,7 @@ async function getDevOutput(dir) {
return stdout + stderr
}
-xdescribe('React 18 Support', () => {
+describe('React 18 Support', () => {
describe('no warns with stable supported version of react-dom', () => {
beforeAll(async () => {
await fs.remove(join(appDir, 'node_modules'))
@@ -106,7 +106,7 @@ xdescribe('React 18 Support', () => {
})
describe('React 18 Basic', () => {
- xdescribe('hydration', () => {
+ describe('Basic hydration', () => {
let app
let appPort
beforeAll(async () => {
@@ -127,7 +127,7 @@ describe('React 18 Basic', () => {
})
})
- describe('dynamic import', () => {
+ describe('Dynamic import', () => {
const context = {}
beforeAll(async () => {