diff --git a/errors/invalid-dynamic-suspense.md b/errors/invalid-dynamic-suspense.md index 11e4d6134d732fa..f016eec897d419f 100644 --- a/errors/invalid-dynamic-suspense.md +++ b/errors/invalid-dynamic-suspense.md @@ -2,11 +2,29 @@ #### Why This Error Occurred -`` is not allowed under legacy render mode when using React older than v18. +- You are using `{ suspense: true }` with React version older than 18. +- You are using `{ suspense: true, ssr: false }`. +- You are using `{ suspense: true, loading }`. #### Possible Ways to Fix It -Remove `suspense: true` option in `next/dynamic` usages. +**If you are using `{ suspense: true }` with React version older than 18** + +- You can try upgrading to React 18 or newer +- If upgrading React is not an option, remove `{ suspense: true }` from `next/dynamic` usages. + +**If you are using `{ suspense: true, ssr: false }`** + +Next.js will use `React.lazy` when `suspense` is set to true. React 18 or newer will always try to resolve the Suspense boundary on the server. This behavior can not be disabled, thus the `ssr: false` is ignored with `suspense: true`. + +- You should write code that works in both client-side and server-side. +- If rewriting the code is not an option, remove `{ suspense: true }` from `next/dynamic` usages. + +**If you are using `{ suspense: true, loading }`** + +Next.js will use `React.lazy` when `suspense` is set to true, when your dynamic-imported component is loading, React will use the closest suspense boundary's fallback. + +You should remove `loading` from `next/dynamic` usages, and use ``'s `fallback` prop. ### Useful Links diff --git a/packages/next/shared/lib/dynamic.tsx b/packages/next/shared/lib/dynamic.tsx index 91b747e4e1ea30e..6e7dc8fa2290cb3 100644 --- a/packages/next/shared/lib/dynamic.tsx +++ b/packages/next/shared/lib/dynamic.tsx @@ -112,6 +112,31 @@ export default function dynamic

( ) } + if (process.env.NODE_ENV !== 'production') { + if (loadableOptions.suspense) { + /** + * TODO: Currently, next/dynamic will opt-in to React.lazy if { suspense: true } is used + * React 18 will always resolve the Suspense boundary on the server-side, effectively ignoring the ssr option + * + * In the future, when React Suspense with third-party libraries is stable, we can implement a custom version of + * React.lazy that can suspense on the server-side while only loading the component on the client-side + */ + if (loadableOptions.ssr === false) { + loadableOptions.ssr = true + console.warn( + `"ssr: false" is ignored by next/dynamic because you can not enable "suspense" while disabling "ssr" at the same time. Read more: https://nextjs.org/docs/messages/invalid-dynamic-suspense` + ) + } + + if (loadableOptions.loading != null) { + loadableOptions.loading = undefined + console.warn( + `"loading" is ignored by next/dynamic because you have enabled "suspense". Place your loading element in your suspense boundary's "fallback" prop instead. Read more: https://nextjs.org/docs/messages/invalid-dynamic-suspense` + ) + } + } + } + // coming from build/babel/plugins/react-loadable-plugin.js if (loadableOptions.loadableGenerated) { loadableOptions = { diff --git a/test/e2e/dynamic-with-suspense/index.test.ts b/test/e2e/dynamic-with-suspense/index.test.ts deleted file mode 100644 index b0f24fb96a1e6e0..000000000000000 --- a/test/e2e/dynamic-with-suspense/index.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createNext } from 'e2e-utils' -import { NextInstance } from 'test/lib/next-modes/base' -import { hasRedbox, renderViaHTTP } from 'next-test-utils' -import webdriver from 'next-webdriver' - -const suite = - process.env.NEXT_TEST_REACT_VERSION === '^17' ? describe.skip : describe - -// Skip the suspense test if react version is 17 -suite('dynamic with suspense', () => { - let next: NextInstance - - beforeAll(async () => { - next = await createNext({ - files: { - 'pages/index.js': ` - import { Suspense } from "react"; - import dynamic from "next/dynamic"; - - const Thing = dynamic(() => import("./thing"), { ssr: false, suspense: true }); - - export default function IndexPage() { - return ( -

-

Next.js Example

- - - -
- ); - } - `, - 'pages/thing.js': ` - export default function Thing() { - return "Thing"; - } - `, - }, - dependencies: {}, - }) - }) - afterAll(() => next.destroy()) - - it('should render server-side', async () => { - const html = await renderViaHTTP(next.url, '/') - expect(html).toContain('Next.js Example') - expect(html).toContain('Thing') - }) - - it('should render client-side', async () => { - const browser = await webdriver(next.url, '/') - expect(await hasRedbox(browser)).toBe(false) - await browser.close() - }) -}) diff --git a/test/integration/next-dynamic-with-suspense/pages/index.js b/test/integration/next-dynamic-with-suspense/pages/index.js new file mode 100644 index 000000000000000..284fcc7f7f8f177 --- /dev/null +++ b/test/integration/next-dynamic-with-suspense/pages/index.js @@ -0,0 +1,19 @@ +import { Suspense } from 'react' +import dynamic from 'next/dynamic' + +const Thing = dynamic(() => import('./thing'), { + ssr: false, + suspense: true, + loading: () => 'Loading...', +}) + +export default function IndexPage() { + return ( +
+

Next.js Example

+ + + +
+ ) +} diff --git a/test/integration/next-dynamic-with-suspense/pages/thing.js b/test/integration/next-dynamic-with-suspense/pages/thing.js new file mode 100644 index 000000000000000..561df9831e5b220 --- /dev/null +++ b/test/integration/next-dynamic-with-suspense/pages/thing.js @@ -0,0 +1,3 @@ +export default function Thing() { + return 'Thing' +} diff --git a/test/integration/next-dynamic-with-suspense/test/index.test.ts b/test/integration/next-dynamic-with-suspense/test/index.test.ts new file mode 100644 index 000000000000000..625fdde999d507b --- /dev/null +++ b/test/integration/next-dynamic-with-suspense/test/index.test.ts @@ -0,0 +1,41 @@ +/* eslint-env jest */ + +import webdriver from 'next-webdriver' +import { join } from 'path' +import { + renderViaHTTP, + findPort, + launchApp, + killApp, + hasRedbox, +} from 'next-test-utils' + +let app +let appPort: number +const appDir = join(__dirname, '../') + +describe('next/dynamic with suspense', () => { + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort) + }) + afterAll(() => killApp(app)) + + it('should render server-side', async () => { + const html = await renderViaHTTP(appPort, '/') + expect(html).toContain('Next.js Example') + expect(html).toContain('Thing') + }) + + it('should render client-side', async () => { + const browser = await webdriver(appPort, '/') + const warnings = (await browser.log()).map((log) => log.message).join('\n') + + expect(await hasRedbox(browser)).toBe(false) + expect(warnings).toMatch( + /"ssr: false" is ignored by next\/dynamic because you can not enable "suspense" while disabling "ssr" at the same time/gim + ) + + await browser.close() + }) +})