diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 570684bb34baeff..82b54a3181dd8dd 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -769,9 +769,9 @@ export default async function getBaseWebpackConfig( if (isLocal) { // Makes sure dist/shared and dist/server are not bundled - // we need to process shared/lib/router/router so that - // the DefinePlugin can inject process.env values - const isNextExternal = /next[/\\]dist[/\\](shared|server)[/\\](?!lib[/\\]router[/\\]router)/.test( + // we need to process shared `router/router` and `dynamic`, + // so that the DefinePlugin can inject process.env values + const isNextExternal = /next[/\\]dist[/\\](shared|server)[/\\](?!lib[/\\](router[/\\]router|dynamic))/.test( res ) @@ -1185,6 +1185,9 @@ export default async function getBaseWebpackConfig( config.reactStrictMode ), 'process.env.__NEXT_REACT_ROOT': JSON.stringify(hasReactRoot), + 'process.env.__NEXT_CONCURRENT_FEATURES': JSON.stringify( + config.experimental.concurrentFeatures && hasReactRoot + ), 'process.env.__NEXT_OPTIMIZE_FONTS': JSON.stringify( config.optimizeFonts && !dev ), diff --git a/packages/next/export/index.ts b/packages/next/export/index.ts index 727037872228910..4e21021eba8761d 100644 --- a/packages/next/export/index.ts +++ b/packages/next/export/index.ts @@ -380,6 +380,7 @@ export default async function exportApp( disableOptimizedLoading: nextConfig.experimental.disableOptimizedLoading, // TODO: We should support dynamic HTML too requireStaticHTML: true, + concurrentFeatures: nextConfig.experimental.concurrentFeatures, } const { serverRuntimeConfig, publicRuntimeConfig } = nextConfig diff --git a/packages/next/shared/lib/dynamic.tsx b/packages/next/shared/lib/dynamic.tsx index 0b260d19e964184..ba5871238998580 100644 --- a/packages/next/shared/lib/dynamic.tsx +++ b/packages/next/shared/lib/dynamic.tsx @@ -33,19 +33,24 @@ export type LoadableBaseOptions

= LoadableGeneratedOptions & { ssr?: boolean } +export type LoadableSuspenseOptions

= { + loader: Loader

+ suspense?: boolean +} + export type LoadableOptions

= LoadableBaseOptions

export type DynamicOptions

= LoadableBaseOptions

export type LoadableFn

= ( - opts: LoadableOptions

+ opts: LoadableOptions

| LoadableSuspenseOptions

) => React.ComponentType

export type LoadableComponent

= React.ComponentType

export function noSSR

( LoadableInitializer: LoadableFn

, - loadableOptions: LoadableOptions

+ loadableOptions: LoadableBaseOptions

): React.ComponentType

{ // Removing webpack and modules means react-loadable won't try preloading delete loadableOptions.webpack @@ -63,8 +68,6 @@ export function noSSR

( ) } -// function dynamic

(options: O): - export default function dynamic

( dynamicOptions: DynamicOptions

| Loader

, options?: DynamicOptions

@@ -110,6 +113,21 @@ export default function dynamic

( // Support for passing options, eg: dynamic(import('../hello-world'), {loading: () =>

Loading something

}) loadableOptions = { ...loadableOptions, ...options } + const suspenseOptions = loadableOptions as LoadableSuspenseOptions

+ if (!process.env.__NEXT_CONCURRENT_FEATURES) { + // Error if react root is not enabled and `suspense` option is set to true + if (!process.env.__NEXT_REACT_ROOT && suspenseOptions.suspense) { + // TODO: add error doc when this feature is stable + throw new Error( + `Disallowed suspense option usage with next/dynamic in blocking mode` + ) + } + suspenseOptions.suspense = false + } + if (suspenseOptions.suspense) { + return loadableFn(suspenseOptions) + } + // coming from build/babel/plugins/react-loadable-plugin.js if (loadableOptions.loadableGenerated) { loadableOptions = { diff --git a/packages/next/shared/lib/loadable.js b/packages/next/shared/lib/loadable.js index 73514019d8321c5..fbeaf9480f6f4e6 100644 --- a/packages/next/shared/lib/loadable.js +++ b/packages/next/shared/lib/loadable.js @@ -66,12 +66,16 @@ function createLoadableComponent(loadFn, options) { timeout: null, webpack: null, modules: null, + suspense: false, }, options ) - let subscription = null + if (opts.suspense) { + opts.lazy = React.lazy(opts.loader) + } + let subscription = null function init() { if (!subscription) { const sub = new LoadableSubscription(loadFn, opts) @@ -86,7 +90,7 @@ function createLoadableComponent(loadFn, options) { } // Server only - if (typeof window === 'undefined') { + if (typeof window === 'undefined' && !opts.suspense) { ALL_INITIALIZERS.push(init) } @@ -95,7 +99,8 @@ function createLoadableComponent(loadFn, options) { !initialized && typeof window !== 'undefined' && typeof opts.webpack === 'function' && - typeof require.resolveWeak === 'function' + typeof require.resolveWeak === 'function' && + !opts.suspense ) { const moduleIds = opts.webpack() READY_INITIALIZERS.push((ids) => { @@ -107,12 +112,11 @@ function createLoadableComponent(loadFn, options) { }) } - const LoadableComponent = (props, ref) => { + function LoadableImpl(props, ref) { init() const context = React.useContext(LoadableContext) const state = useSubscription(subscription) - React.useImperativeHandle( ref, () => ({ @@ -144,7 +148,12 @@ function createLoadableComponent(loadFn, options) { }, [props, state]) } - LoadableComponent.preload = () => init() + function LazyImpl(props, ref) { + return React.createElement(opts.lazy, { ...props, ref }) + } + + const LoadableComponent = opts.suspense ? LazyImpl : LoadableImpl + LoadableComponent.preload = () => !opts.suspense && init() LoadableComponent.displayName = 'LoadableComponent' return React.forwardRef(LoadableComponent) diff --git a/test/integration/chunking/test/index.test.js b/test/integration/chunking/test/index.test.js index 24bcfd92d2d509c..a3c2bab19fafff7 100644 --- a/test/integration/chunking/test/index.test.js +++ b/test/integration/chunking/test/index.test.js @@ -84,7 +84,6 @@ describe('Chunking', () => { it('should execute the build manifest', async () => { const html = await renderViaHTTP(appPort, '/') - console.log(html) const $ = cheerio.load(html) expect( Array.from($('script')) diff --git a/test/integration/react-18/app/components/bar.js b/test/integration/react-18/app/components/bar.js new file mode 100644 index 000000000000000..44a8eb0864dc14f --- /dev/null +++ b/test/integration/react-18/app/components/bar.js @@ -0,0 +1,24 @@ +import { Suspense } from 'react' +import dynamic from 'next/dynamic' +import { useCachedPromise } from './promise-cache' + +const Foo = dynamic(() => import('./foo'), { + suspense: true, +}) + +export default function Bar() { + useCachedPromise( + 'bar', + () => new Promise((resolve) => setTimeout(resolve, 300)), + true + ) + + return ( +

+ bar + + + +
+ ) +} diff --git a/test/integration/react-18/app/components/dynamic-hello.js b/test/integration/react-18/app/components/dynamic-hello.js new file mode 100644 index 000000000000000..61d0ecaea9b8804 --- /dev/null +++ b/test/integration/react-18/app/components/dynamic-hello.js @@ -0,0 +1,18 @@ +import { Suspense } from 'react' +import dynamic from 'next/dynamic' + +let ssr +const suspense = false + +const Hello = dynamic(() => import('./hello'), { + ssr, + suspense, +}) + +export default function DynamicHello(props) { + return ( + + + + ) +} diff --git a/test/integration/react-18/app/components/foo.js b/test/integration/react-18/app/components/foo.js new file mode 100644 index 000000000000000..3689dc33e306065 --- /dev/null +++ b/test/integration/react-18/app/components/foo.js @@ -0,0 +1,3 @@ +export default function Foo() { + return 'foo' +} diff --git a/test/integration/react-18/app/components/hello.js b/test/integration/react-18/app/components/hello.js new file mode 100644 index 000000000000000..fc4d62a533fad10 --- /dev/null +++ b/test/integration/react-18/app/components/hello.js @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { useCachedPromise } from './promise-cache' + +export default function Hello({ name, thrown = false }) { + useCachedPromise( + name, + () => new Promise((resolve) => setTimeout(resolve, 200)), + thrown + ) + + return

hello {ReactDOM.version}

+} diff --git a/test/integration/react-18/app/components/promise-cache.js b/test/integration/react-18/app/components/promise-cache.js new file mode 100644 index 000000000000000..eab490fb9ae9282 --- /dev/null +++ b/test/integration/react-18/app/components/promise-cache.js @@ -0,0 +1,37 @@ +import React from 'react' + +const PromiseCacheContext = React.createContext(null) + +export const cache = new Map() +export const PromiseCacheProvider = PromiseCacheContext.Provider + +export function useCachedPromise(key, fn, thrown = false) { + const cache = React.useContext(PromiseCacheContext) + + if (!thrown) return undefined + let entry = cache.get(key) + if (!entry) { + entry = { + status: 'PENDING', + value: fn().then( + (value) => { + cache.set(key, { + status: 'RESOLVED', + value, + }) + }, + (err) => { + cache.set(key, { + status: 'REJECTED', + value: err, + }) + } + ), + } + cache.set(key, entry) + } + if (['PENDING', 'REJECTED'].includes(entry.status)) { + throw entry.value + } + return entry.value +} diff --git a/test/integration/react-18/prerelease/next.config.js b/test/integration/react-18/app/next.config.js similarity index 87% rename from test/integration/react-18/prerelease/next.config.js rename to test/integration/react-18/app/next.config.js index d8f907bce9fce52..3604bd2c1ac01a7 100644 --- a/test/integration/react-18/prerelease/next.config.js +++ b/test/integration/react-18/app/next.config.js @@ -1,4 +1,8 @@ module.exports = { + experimental: { + reactRoot: true, + // concurrentFeatures: true, + }, webpack(config) { const { alias } = config.resolve // FIXME: resolving react/jsx-runtime https://github.com/facebook/react/issues/20235 diff --git a/test/integration/react-18/app/package.json b/test/integration/react-18/app/package.json new file mode 100644 index 000000000000000..f9dafc993a79cae --- /dev/null +++ b/test/integration/react-18/app/package.json @@ -0,0 +1,12 @@ +{ + "scripts": { + "next": "node -r ../test/require-hook.js ../../../../packages/next/dist/bin/next", + "dev": "yarn next dev", + "build": "yarn next build", + "start": "yarn next start" + }, + "dependencies": { + "react": "*", + "react-dom": "*" + } +} diff --git a/test/integration/react-18/app/pages/_app.js b/test/integration/react-18/app/pages/_app.js new file mode 100644 index 000000000000000..db9e691d37e0254 --- /dev/null +++ b/test/integration/react-18/app/pages/_app.js @@ -0,0 +1,13 @@ +import { PromiseCacheProvider } from '../components/promise-cache' + +const cache = new Map() + +function MyApp({ Component, pageProps }) { + return ( + + + + ) +} + +export default MyApp diff --git a/test/integration/react-18/app/pages/index.js b/test/integration/react-18/app/pages/index.js new file mode 100644 index 000000000000000..ebca78cecee9e7f --- /dev/null +++ b/test/integration/react-18/app/pages/index.js @@ -0,0 +1,12 @@ +import ReactDOM from 'react-dom' + +export default function Index() { + if (typeof window !== 'undefined') { + window.didHydrate = true + } + return ( +
+

{ReactDOM.version}

+
+ ) +} diff --git a/test/integration/react-18/app/pages/suspense/no-preload.js b/test/integration/react-18/app/pages/suspense/no-preload.js new file mode 100644 index 000000000000000..aaf54e244118fff --- /dev/null +++ b/test/integration/react-18/app/pages/suspense/no-preload.js @@ -0,0 +1,21 @@ +import { Suspense } from 'react' +import dynamic from 'next/dynamic' + +const Bar = dynamic(() => import('../../components/bar'), { + suspense: true, + // Explicitly declare loaded modules. + // For suspense cases, they'll be ignored. + // For loadable component cases, they'll be handled + loadableGenerated: { + modules: ['../../components/bar'], + webpack: [require.resolveWeak('../../components/bar')], + }, +}) + +export default function NoPreload() { + return ( + + + + ) +} diff --git a/test/integration/react-18/app/pages/suspense/no-thrown.js b/test/integration/react-18/app/pages/suspense/no-thrown.js new file mode 100644 index 000000000000000..123ab2d39b0fefd --- /dev/null +++ b/test/integration/react-18/app/pages/suspense/no-thrown.js @@ -0,0 +1,5 @@ +import DynamicHello from '../../components/dynamic-hello' + +export default function NoThrown() { + return +} diff --git a/test/integration/react-18/app/pages/suspense/thrown.js b/test/integration/react-18/app/pages/suspense/thrown.js new file mode 100644 index 000000000000000..3f8d40a49ac4fce --- /dev/null +++ b/test/integration/react-18/app/pages/suspense/thrown.js @@ -0,0 +1,5 @@ +import DynamicHello from '../../components/dynamic-hello' + +export default function Thrown() { + return +} diff --git a/test/integration/react-18/app/pages/suspense/unwrapped.js b/test/integration/react-18/app/pages/suspense/unwrapped.js new file mode 100644 index 000000000000000..3909e80679a6a9d --- /dev/null +++ b/test/integration/react-18/app/pages/suspense/unwrapped.js @@ -0,0 +1,10 @@ +import React from 'react' +import dynamic from 'next/dynamic' + +const Hello = dynamic(() => import('../../components/hello'), { + suspense: true, +}) + +export default function Unwrapped() { + return +} diff --git a/test/integration/react-18/prerelease/.gitignore b/test/integration/react-18/prerelease/.gitignore deleted file mode 100644 index 736e8ae58ad87f0..000000000000000 --- a/test/integration/react-18/prerelease/.gitignore +++ /dev/null @@ -1 +0,0 @@ -!node_modules \ No newline at end of file diff --git a/test/integration/react-18/prerelease/node_modules/react-dom/package.json b/test/integration/react-18/prerelease/node_modules/react-dom/package.json deleted file mode 100644 index 8986c6dd8a0748e..000000000000000 --- a/test/integration/react-18/prerelease/node_modules/react-dom/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "react-dom", - "version": "18.0.0-alpha-c76e4dbbc-20210722" -} diff --git a/test/integration/react-18/prerelease/package.json b/test/integration/react-18/prerelease/package.json deleted file mode 100644 index be823a58af7d766..000000000000000 --- a/test/integration/react-18/prerelease/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "dependencies": { - "react": "*", - "react-dom": "*" - } -} diff --git a/test/integration/react-18/prerelease/pages/bar.js b/test/integration/react-18/prerelease/pages/bar.js deleted file mode 100644 index 3b3b63d19aee596..000000000000000 --- a/test/integration/react-18/prerelease/pages/bar.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function Bar() { - return
bar
-} diff --git a/test/integration/react-18/prerelease/pages/index.js b/test/integration/react-18/prerelease/pages/index.js deleted file mode 100644 index 4381bead3f6409b..000000000000000 --- a/test/integration/react-18/prerelease/pages/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Suspense } from 'react' -import Bar from './bar' - -export default function Index() { - if (typeof window !== 'undefined') { - window.didHydrate = true - } - return ( -
-

Hello

- - - -
- ) -} diff --git a/test/integration/react-18/supported/pages/index.js b/test/integration/react-18/supported/pages/index.js deleted file mode 100644 index fb077e8078c9e51..000000000000000 --- a/test/integration/react-18/supported/pages/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function Index() { - return

Hello

-} diff --git a/test/integration/react-18/test/basics.js b/test/integration/react-18/test/basics.js new file mode 100644 index 000000000000000..bb3c4ad99ced4f7 --- /dev/null +++ b/test/integration/react-18/test/basics.js @@ -0,0 +1,31 @@ +/* eslint-env jest */ + +import webdriver from 'next-webdriver' +import cheerio from 'cheerio' +import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils' + +export default (context) => { + it('hydrates correctly for normal page', async () => { + const browser = await webdriver(context.appPort, '/') + expect(await browser.eval('window.didHydrate')).toBe(true) + expect(await browser.elementById('react-dom-version').text()).toMatch(/18/) + }) + + it('should works with suspense in ssg', async () => { + const res1 = await fetchViaHTTP(context.appPort, '/suspense/thrown') + const res2 = await fetchViaHTTP(context.appPort, '/suspense/no-thrown') + + expect(res1.status).toBe(200) + expect(res2.status).toBe(200) + }) + + it('should render fallback without preloads on server side', async () => { + const html = await renderViaHTTP(context.appPort, '/suspense/no-preload') + const $ = cheerio.load(html) + const nextData = JSON.parse($('#__NEXT_DATA__').text()) + const content = $('#__next').text() + // is suspended + expect(content).toBe('rab') + expect(nextData.dynamicIds).toBeUndefined() + }) +} diff --git a/test/integration/react-18/test/blocking.js b/test/integration/react-18/test/blocking.js new file mode 100644 index 000000000000000..3adb476c5a018b2 --- /dev/null +++ b/test/integration/react-18/test/blocking.js @@ -0,0 +1,27 @@ +/* eslint-env jest */ + +import cheerio from 'cheerio' + +export default (context, render) => { + async function get$(path, query) { + const html = await render(path, query) + return cheerio.load(html) + } + + it('should render fallback on server side if suspense without preload', async () => { + const $ = await get$('/suspense/no-preload') + const nextData = JSON.parse($('#__NEXT_DATA__').text()) + const content = $('#__next').text() + expect(content).toBe('rab') + expect(nextData.dynamicIds).toBeUndefined() + }) + + it('should render fallback on server side if suspended on server with preload', async () => { + const $ = await get$('/suspense/thrown') + const html = $('body').html() + expect(html).toContain('loading') + expect( + JSON.parse($('#__NEXT_DATA__').text()).dynamicIds + ).not.toBeUndefined() + }) +} diff --git a/test/integration/react-18/test/concurrent.js b/test/integration/react-18/test/concurrent.js new file mode 100644 index 000000000000000..e2aec94e452bd4b --- /dev/null +++ b/test/integration/react-18/test/concurrent.js @@ -0,0 +1,47 @@ +/* eslint-env jest */ + +import webdriver from 'next-webdriver' +import cheerio from 'cheerio' +import { check } from 'next-test-utils' + +export default (context, render) => { + async function get$(path, query) { + const html = await render(path, query) + return cheerio.load(html) + } + + it('should resolve suspense modules on server side if suspense', async () => { + const $ = await get$('/suspense/no-preload') + const nextData = JSON.parse($('#__NEXT_DATA__').text()) + const content = $('#__next').text() + expect(content).toBe('barfoo') + expect(nextData.dynamicIds).toBeUndefined() + }) + + it('should resolve suspense on server side if not suspended on server', async () => { + const $ = await get$('/suspense/no-thrown') + const html = $('body').html() + // there might be html comments between text, test hello only + expect(html).toContain('hello') + expect(JSON.parse($('#__NEXT_DATA__').text()).dynamicIds).toBeUndefined() + }) + + it('should resolve suspense on server side if suspended on server', async () => { + const $ = await get$('/suspense/thrown') + const html = $('body').html() + expect(html).toContain('hello') + expect(JSON.parse($('#__NEXT_DATA__').text()).dynamicIds).toBeUndefined() + }) + + it('should hydrate suspenses on client side if suspended on server', async () => { + let browser + try { + browser = await webdriver(context.appPort, '/suspense/thrown') + await check(() => browser.elementByCss('body').text(), /hello/) + } finally { + if (browser) { + await browser.close() + } + } + }) +} diff --git a/test/integration/react-18/test/index.test.js b/test/integration/react-18/test/index.test.js index 34d7f9067880fc5..d0f889adcccd1bc 100644 --- a/test/integration/react-18/test/index.test.js +++ b/test/integration/react-18/test/index.test.js @@ -2,22 +2,30 @@ import { join } from 'path' import fs from 'fs-extra' -import webdriver from 'next-webdriver' + import { + File, findPort, killApp, launchApp, nextBuild, nextStart, + renderViaHTTP, } from 'next-test-utils' +import blocking from './blocking' +import concurrent from './concurrent' +import basics from './basics' jest.setTimeout(1000 * 60 * 5) // overrides react and react-dom to v18 const nodeArgs = ['-r', join(__dirname, 'require-hook.js')] -const dirSupported = join(__dirname, '../supported') -const dirPrerelease = join(__dirname, '../prerelease') +const appDir = join(__dirname, '../app') +const nextConfig = new File(join(appDir, 'next.config.js')) +const dynamicHello = new File(join(appDir, 'components/dynamic-hello.js')) +const SUSPENSE_ERROR_MESSAGE = + 'Disallowed suspense option usage with next/dynamic' const UNSUPPORTED_PRERELEASE = "You are using an unsupported prerelease of 'react-dom'" const USING_CREATE_ROOT = 'Using the createRoot API for React' @@ -52,54 +60,129 @@ async function getDevOutput(dir) { } describe('React 18 Support', () => { - describe('build', () => { - test('supported version of React', async () => { - const output = await getBuildOutput(dirSupported) + describe('no warns with stable supported version of react-dom', () => { + beforeAll(async () => { + await fs.remove(join(appDir, 'node_modules')) + nextConfig.replace('reactRoot: true', '// reactRoot: true') + }) + afterAll(() => { + nextConfig.replace('// reactRoot: true', 'reactRoot: true') + }) + + test('supported version of react in dev', async () => { + const output = await getDevOutput(appDir) expect(output).not.toMatch(USING_CREATE_ROOT) expect(output).not.toMatch(UNSUPPORTED_PRERELEASE) }) - test('prerelease version of React', async () => { - const output = await getBuildOutput(dirPrerelease) - expect(output).toMatch(USING_CREATE_ROOT) - expect(output).toMatch(UNSUPPORTED_PRERELEASE) + test('supported version of react in build', async () => { + const output = await getBuildOutput(appDir) + expect(output).not.toMatch(USING_CREATE_ROOT) + expect(output).not.toMatch(UNSUPPORTED_PRERELEASE) + }) + + it('suspense is not allowed in blocking rendering mode', async () => { + const appPort = await findPort() + const app = await launchApp(appDir, appPort) + const html = await renderViaHTTP(appPort, '/suspense/unwrapped') + await killApp(app) + expect(html).toContain(SUSPENSE_ERROR_MESSAGE) }) }) - describe('dev', () => { - test('supported version of React', async () => { - let output = await getDevOutput(dirSupported) - expect(output).not.toMatch(USING_CREATE_ROOT) - expect(output).not.toMatch(UNSUPPORTED_PRERELEASE) + describe('warns with stable supported version of react-dom', () => { + beforeAll(async () => { + const reactDomPkgPath = join( + appDir, + 'node_modules/react-dom/package.json' + ) + await fs.outputJson(reactDomPkgPath, { + name: 'react-dom', + version: '18.0.0-alpha-c76e4dbbc-20210722', + }) + }) + afterAll(async () => await fs.remove(join(appDir, 'node_modules'))) + + test('prerelease version of react in dev', async () => { + const output = await getDevOutput(appDir) + expect(output).toMatch(USING_CREATE_ROOT) + expect(output).toMatch(UNSUPPORTED_PRERELEASE) }) - test('prerelease version of React', async () => { - let output = await getDevOutput(dirPrerelease) + test('prerelease version of react in build', async () => { + const output = await getBuildOutput(appDir) expect(output).toMatch(USING_CREATE_ROOT) expect(output).toMatch(UNSUPPORTED_PRERELEASE) }) }) +}) + +describe('Basics', () => { + runTests('default setting with react 18', 'dev', (context) => basics(context)) + runTests('default setting with react 18', 'prod', (context) => + basics(context) + ) +}) + +describe('Blocking mode', () => { + beforeAll(() => { + dynamicHello.replace('suspense = false', `suspense = true`) + }) + afterAll(() => { + dynamicHello.restore() + }) + + runTests('concurrentFeatures is disabled', 'dev', (context) => + blocking(context, (p, q) => renderViaHTTP(context.appPort, p, q)) + ) - describe('hydration', () => { - const appDir = join(__dirname, '../prerelease') - let app - let appPort + runTests('concurrentFeatures is disabled', 'prod', (context) => + blocking(context, (p, q) => renderViaHTTP(context.appPort, p, q)) + ) +}) + +describe('Concurrent mode', () => { + beforeAll(async () => { + nextConfig.replace( + '// concurrentFeatures: true', + 'concurrentFeatures: true' + ) + dynamicHello.replace('suspense = false', `suspense = true`) + // `noSSR` mode will be ignored by suspense + dynamicHello.replace('let ssr', `let ssr = false`) + }) + afterAll(async () => { + nextConfig.restore() + dynamicHello.restore() + }) + + runTests('concurrentFeatures is enabled', 'dev', (context) => + concurrent(context, (p, q) => renderViaHTTP(context.appPort, p, q)) + ) + runTests('concurrentFeatures is enabled', 'prod', (context) => + concurrent(context, (p, q) => renderViaHTTP(context.appPort, p, q)) + ) +}) + +function runTests(name, mode, fn) { + const context = { appDir } + describe(`${name} (${mode})`, () => { beforeAll(async () => { - await fs.remove(join(appDir, '.next')) - await nextBuild(appDir, [dirPrerelease], { - nodeArgs, - stdout: true, - stderr: true, - }) - appPort = await findPort() - app = await nextStart(appDir, appPort, { nodeArgs }) + context.appPort = await findPort() + if (mode === 'dev') { + context.server = await launchApp(context.appDir, context.appPort, { + nodeArgs, + }) + } else { + await nextBuild(context.appDir, [], { nodeArgs }) + context.server = await nextStart(context.appDir, context.appPort, { + nodeArgs, + }) + } }) afterAll(async () => { - await killApp(app) - }) - it('hydrates correctly for normal page', async () => { - const browser = await webdriver(appPort, '/') - expect(await browser.eval('window.didHydrate')).toBe(true) + await killApp(context.server) }) + fn(context) }) -}) +} diff --git a/test/unit/next-dynamic.test.js b/test/unit/next-dynamic.test.js index 31477e93f00ef82..e78e15d6ff0efa7 100644 --- a/test/unit/next-dynamic.test.js +++ b/test/unit/next-dynamic.test.js @@ -6,7 +6,7 @@ import { act, render } from '@testing-library/react' import dynamic from 'next/dynamic' describe('next/dynamic', () => { - it('test link with unmount', () => { + it('test dynamic with jest', () => { const App = dynamic(() => import('./fixtures/stub-components/hello')) act(() => { const { unmount } = render()