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 () => {