diff --git a/.eslintrc.json b/.eslintrc.json index 722a752fddfa8..ee19359cb2451 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -45,7 +45,11 @@ "jest/no-conditional-expect": "off", "jest/valid-title": "off", "jest/no-interpolation-in-snapshots": "off", - "jest/no-export": "off" + "jest/no-export": "off", + "jest/no-standalone-expect": [ + "error", + { "additionalTestBlockFunctions": ["gateReact18"] } + ] } }, { "files": ["**/__tests__/**"], "env": { "jest": true } }, diff --git a/packages/next/package.json b/packages/next/package.json index a8b20b90e6868..0fda568054c35 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -108,8 +108,8 @@ "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", - "react": "19.0.0-rc-a99d8e8d-20240916", - "react-dom": "19.0.0-rc-a99d8e8d-20240916", + "react": "^18.2.0 || 19.0.0-rc-a99d8e8d-20240916", + "react-dom": "^18.2.0 || 19.0.0-rc-a99d8e8d-20240916", "sass": "^1.3.0" }, "peerDependenciesMeta": { diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index b49f30e760180..a0dab2c6eacaf 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -107,8 +107,8 @@ const NEXT_PROJECT_ROOT_DIST_CLIENT = path.join( 'client' ) -if (parseInt(React.version) < 19) { - throw new Error('Next.js requires react >= 19.0.0 to be installed.') +if (parseInt(React.version) < 18) { + throw new Error('Next.js requires react >= 18.2.0 to be installed.') } export const babelIncludeRegexes: RegExp[] = [ diff --git a/packages/next/src/client/legacy/image.tsx b/packages/next/src/client/legacy/image.tsx index aa79f37ac05f5..40116cea747e8 100644 --- a/packages/next/src/client/legacy/image.tsx +++ b/packages/next/src/client/legacy/image.tsx @@ -9,6 +9,8 @@ import React, { useState, type JSX, } from 'react' +import * as ReactDOM from 'react-dom' +import Head from '../../shared/lib/head' import { imageConfigDefault, VALID_LOADERS, @@ -26,6 +28,8 @@ function normalizeSrc(src: string): string { return src[0] === '/' ? src.slice(1) : src } +const supportsFloat = typeof ReactDOM.preload === 'function' + const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete const loadedImageURLs = new Set() const allImgs = new Map< @@ -978,6 +982,20 @@ export default function Image({ } } + const linkProps: + | React.DetailedHTMLProps< + React.LinkHTMLAttributes, + HTMLLinkElement + > + | undefined = supportsFloat + ? undefined + : { + imageSrcSet: imgAttributes.srcSet, + imageSizes: imgAttributes.sizes, + crossOrigin: rest.crossOrigin, + referrerPolicy: rest.referrerPolicy, + } + const useLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect const onLoadingCompleteRef = useRef(onLoadingComplete) @@ -1044,6 +1062,27 @@ export default function Image({ ) : null} + {!supportsFloat && priority ? ( + // Note how we omit the `href` attribute, as it would only be relevant + // for browsers that do not support `imagesrcset`, and in those cases + // it would likely cause the incorrect image to be preloaded. + // + // https://html.spec.whatwg.org/multipage/semantics.html#attr-link-imagesrcset + + + + ) : null} ) } diff --git a/packages/next/src/client/use-merged-ref.ts b/packages/next/src/client/use-merged-ref.ts index 7fce7fb1f0ee1..65bb8dd4dcb42 100644 --- a/packages/next/src/client/use-merged-ref.ts +++ b/packages/next/src/client/use-merged-ref.ts @@ -1,29 +1,34 @@ -import { useMemo, type Ref } from 'react' +import { useMemo, useRef, type Ref } from 'react' +// This is a compatibility hook to support React 18 and 19 refs. +// In 19, a cleanup function from refs may be returned. +// In 18, returning a cleanup function creates a warning. +// Since we take userspace refs, we don't know ahead of time if a cleanup function will be returned. +// This implements cleanup functions with the old behavior in 18. +// We know refs are always called alternating with `null` and then `T`. +// So a call with `null` means we need to call the previous cleanup functions. export function useMergedRef( refA: Ref, refB: Ref ): Ref { - return useMemo(() => mergeRefs(refA, refB), [refA, refB]) -} + const cleanupA = useRef<() => void>(() => {}) + const cleanupB = useRef<() => void>(() => {}) -export function mergeRefs( - refA: Ref, - refB: Ref -): Ref { - if (!refA || !refB) { - return refA || refB - } - - return (current: TElement) => { - const cleanupA = applyRef(refA, current) - const cleanupB = applyRef(refB, current) + return useMemo(() => { + if (!refA || !refB) { + return refA || refB + } - return () => { - cleanupA() - cleanupB() + return (current: TElement | null): void => { + if (current === null) { + cleanupA.current() + cleanupB.current() + } else { + cleanupA.current = applyRef(refA, current) + cleanupB.current = applyRef(refB, current) + } } - } + }, [refA, refB]) } function applyRef( diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index 2d3433e2e9922..70d7cb5c4f2f7 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -40,7 +40,7 @@ import type { Revalidate, SwrDelta } from './lib/revalidate' import type { COMPILER_NAMES } from '../shared/lib/constants' import React, { type JSX } from 'react' -import ReactDOMServerEdge from 'react-dom/server.edge' +import ReactDOMServerBrowser from 'react-dom/server.browser' import { StyleRegistry, createStyleRegistry } from 'styled-jsx' import { GSP_NO_RETURNED_VALUE, @@ -127,7 +127,8 @@ function noRouter() { } async function renderToString(element: React.ReactElement) { - const renderStream = await ReactDOMServerEdge.renderToReadableStream(element) + const renderStream = + await ReactDOMServerBrowser.renderToReadableStream(element) await renderStream.allReady return streamToString(renderStream) } @@ -1326,7 +1327,7 @@ export async function renderToHTMLImpl( ) => { const content = renderContent(EnhancedApp, EnhancedComponent) return await renderToInitialFizzStream({ - ReactDOMServer: ReactDOMServerEdge, + ReactDOMServer: ReactDOMServerBrowser, element: content, }) } diff --git a/packages/next/types/react-dom.d.ts b/packages/next/types/react-dom.d.ts index 9b811922e4d55..7c11484f3bb36 100644 --- a/packages/next/types/react-dom.d.ts +++ b/packages/next/types/react-dom.d.ts @@ -70,6 +70,10 @@ declare module 'react-dom/server.edge' { > } +declare module 'react-dom/server.browser' { + export * from 'react-dom/server.edge' +} + declare module 'react-dom/static.edge' { import type { JSX } from 'react' /** diff --git a/packages/next/webpack.config.js b/packages/next/webpack.config.js index 51e2090a11643..7f4af265bf73c 100644 --- a/packages/next/webpack.config.js +++ b/packages/next/webpack.config.js @@ -13,6 +13,7 @@ const pagesExternals = [ 'react-dom/package.json', 'react-dom/client', 'react-dom/server', + 'react-dom/server.browser', 'react-dom/server.edge', 'react-server-dom-webpack/client', 'react-server-dom-webpack/client.edge', diff --git a/test/development/app-dir/ssr-in-rsc/ssr-in-rsc.test.ts b/test/development/app-dir/ssr-in-rsc/ssr-in-rsc.test.ts index 2f38cf34080cb..4e669be87b811 100644 --- a/test/development/app-dir/ssr-in-rsc/ssr-in-rsc.test.ts +++ b/test/development/app-dir/ssr-in-rsc/ssr-in-rsc.test.ts @@ -8,6 +8,7 @@ import { } from 'next-test-utils' const isReactExperimental = process.env.__NEXT_EXPERIMENTAL_PPR === 'true' +const isReact18 = parseInt(process.env.NEXT_TEST_REACT_VERSION) === 18 describe('react-dom/server in React Server environment', () => { const dependencies = (global as any).isNextDeploy @@ -317,23 +318,45 @@ describe('react-dom/server in React Server environment', () => { source: await getRedboxSource(browser), } if (isTurbopack) { - expect(redbox).toMatchInlineSnapshot(` - { - "description": "Error: react-dom/server is not supported in React Server Components.", - "source": "app/exports/app-code/react-dom-server-node-explicit/page.js (0:0) @ + if (isReact18) { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "TypeError: Cannot read properties of undefined (reading 'ReactCurrentDispatcher')", + "source": "app/exports/app-code/react-dom-server-node-explicit/page.js (0:0) @ - 1 | import * as ReactDOMServerNode from 'react-dom/server.node' - 2 | // Fine to drop once React is on ESM - 3 | import ReactDOMServerNodeDefault from 'react-dom/server.node'", - } - `) + 1 | import * as ReactDOMServerNode from 'react-dom/server.node' + 2 | // Fine to drop once React is on ESM + 3 | import ReactDOMServerNodeDefault from 'react-dom/server.node'", + } + `) + } else { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "Error: react-dom/server is not supported in React Server Components.", + "source": "app/exports/app-code/react-dom-server-node-explicit/page.js (0:0) @ + + 1 | import * as ReactDOMServerNode from 'react-dom/server.node' + 2 | // Fine to drop once React is on ESM + 3 | import ReactDOMServerNodeDefault from 'react-dom/server.node'", + } + `) + } } else { - expect(redbox).toMatchInlineSnapshot(` - { - "description": "Error: react-dom/server is not supported in React Server Components.", - "source": null, - } - `) + if (isReact18) { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "TypeError: Cannot read properties of undefined (reading 'ReactCurrentDispatcher')", + "source": null, + } + `) + } else { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "Error: react-dom/server is not supported in React Server Components.", + "source": null, + } + `) + } } }) @@ -404,23 +427,45 @@ describe('react-dom/server in React Server environment', () => { source: await getRedboxSource(browser), } if (isTurbopack) { - expect(redbox).toMatchInlineSnapshot(` - { - "description": "Error: react-dom/server is not supported in React Server Components.", - "source": "internal-pkg/server.node.js (0:0) @ + if (isReact18) { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "TypeError: Cannot read properties of undefined (reading 'ReactCurrentDispatcher')", + "source": "internal-pkg/server.node.js (0:0) @ - 1 | import * as ReactDOMServerEdge from 'react-dom/server.node' - 2 | // Fine to drop once React is on ESM - 3 | import ReactDOMServerEdgeDefault from 'react-dom/server.node'", - } - `) + 1 | import * as ReactDOMServerEdge from 'react-dom/server.node' + 2 | // Fine to drop once React is on ESM + 3 | import ReactDOMServerEdgeDefault from 'react-dom/server.node'", + } + `) + } else { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "Error: react-dom/server is not supported in React Server Components.", + "source": "internal-pkg/server.node.js (0:0) @ + + 1 | import * as ReactDOMServerEdge from 'react-dom/server.node' + 2 | // Fine to drop once React is on ESM + 3 | import ReactDOMServerEdgeDefault from 'react-dom/server.node'", + } + `) + } } else { - expect(redbox).toMatchInlineSnapshot(` - { + if (isReact18) { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "TypeError: Cannot read properties of undefined (reading 'ReactCurrentDispatcher')", + "source": null, + } + `) + } else { + expect(redbox).toMatchInlineSnapshot(` + { "description": "Error: react-dom/server is not supported in React Server Components.", - "source": null, - } - `) + "source": null, + } + `) + } } }) @@ -501,26 +546,27 @@ describe('react-dom/server in React Server environment', () => { }" `) } else { + await assertNoRedbox(browser) expect(await browser.elementByCss('main').text()) .toMatchInlineSnapshot(` - "{ - "default": { - "default": [ - "renderToReadableStream", - "renderToStaticMarkup", - "renderToString", - "version" - ], - "named": [ - "default", - "renderToReadableStream", - "renderToStaticMarkup", - "renderToString", - "version" - ] - } - }" - `) + "{ + "default": { + "default": [ + "renderToReadableStream", + "renderToStaticMarkup", + "renderToString", + "version" + ], + "named": [ + "default", + "renderToReadableStream", + "renderToStaticMarkup", + "renderToString", + "version" + ] + } + }" + `) } } const redbox = { @@ -675,23 +721,45 @@ describe('react-dom/server in React Server environment', () => { source: await getRedboxSource(browser), } if (isTurbopack) { - expect(redbox).toMatchInlineSnapshot(` - { - "description": "Error: react-dom/server is not supported in React Server Components.", - "source": "internal-pkg/server.node.js (0:0) @ + if (isReact18) { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "TypeError: Cannot read properties of undefined (reading 'ReactCurrentDispatcher')", + "source": "internal-pkg/server.node.js (0:0) @ - 1 | import * as ReactDOMServerEdge from 'react-dom/server.node' - 2 | // Fine to drop once React is on ESM - 3 | import ReactDOMServerEdgeDefault from 'react-dom/server.node'", - } - `) + 1 | import * as ReactDOMServerEdge from 'react-dom/server.node' + 2 | // Fine to drop once React is on ESM + 3 | import ReactDOMServerEdgeDefault from 'react-dom/server.node'", + } + `) + } else { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "Error: react-dom/server is not supported in React Server Components.", + "source": "internal-pkg/server.node.js (0:0) @ + + 1 | import * as ReactDOMServerEdge from 'react-dom/server.node' + 2 | // Fine to drop once React is on ESM + 3 | import ReactDOMServerEdgeDefault from 'react-dom/server.node'", + } + `) + } } else { - expect(redbox).toMatchInlineSnapshot(` - { - "description": "Error: react-dom/server is not supported in React Server Components.", - "source": null, - } - `) + if (isReact18) { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "TypeError: Cannot read properties of undefined (reading 'ReactCurrentDispatcher')", + "source": null, + } + `) + } else { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "Error: react-dom/server is not supported in React Server Components.", + "source": null, + } + `) + } } }) @@ -707,23 +775,45 @@ describe('react-dom/server in React Server environment', () => { } if (isTurbopack) { - expect(redbox).toMatchInlineSnapshot(` - { - "description": "Error: react-dom/server is not supported in React Server Components.", - "source": "internal-pkg/server.node.js (0:0) @ + if (isReact18) { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "TypeError: Cannot read properties of undefined (reading 'ReactCurrentDispatcher')", + "source": "internal-pkg/server.node.js (0:0) @ - 1 | import * as ReactDOMServerEdge from 'react-dom/server.node' - 2 | // Fine to drop once React is on ESM - 3 | import ReactDOMServerEdgeDefault from 'react-dom/server.node'", - } - `) + 1 | import * as ReactDOMServerEdge from 'react-dom/server.node' + 2 | // Fine to drop once React is on ESM + 3 | import ReactDOMServerEdgeDefault from 'react-dom/server.node'", + } + `) + } else { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "Error: react-dom/server is not supported in React Server Components.", + "source": "internal-pkg/server.node.js (0:0) @ + + 1 | import * as ReactDOMServerEdge from 'react-dom/server.node' + 2 | // Fine to drop once React is on ESM + 3 | import ReactDOMServerEdgeDefault from 'react-dom/server.node'", + } + `) + } } else { - expect(redbox).toMatchInlineSnapshot(` - { - "description": "Error: react-dom/server is not supported in React Server Components.", - "source": null, - } - `) + if (isReact18) { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "TypeError: Cannot read properties of undefined (reading 'ReactCurrentDispatcher')", + "source": null, + } + `) + } else { + expect(redbox).toMatchInlineSnapshot(` + { + "description": "Error: react-dom/server is not supported in React Server Components.", + "source": null, + } + `) + } } }) }) diff --git a/test/development/basic/hmr.test.ts b/test/development/basic/hmr.test.ts index 3dad5302c2cb8..f17c557d0e36c 100644 --- a/test/development/basic/hmr.test.ts +++ b/test/development/basic/hmr.test.ts @@ -18,6 +18,8 @@ import { NextInstance } from 'e2e-utils' import { outdent } from 'outdent' import type { NextConfig } from 'next' +const isReact18 = parseInt(process.env.NEXT_TEST_REACT_VERSION) === 18 + describe.each([ { basePath: '', assetPrefix: '' }, { basePath: '', assetPrefix: '/asset-prefix' }, @@ -41,11 +43,14 @@ describe.each([ }) await retry(async () => { const logs = await browser.log() + expect(logs).toEqual( expect.arrayContaining([ { message: expect.stringContaining( - 'https://react.dev/link/hydration-mismatch' + isReact18 + ? 'https://nextjs.org/docs/messages/react-hydration-error' + : 'https://react.dev/link/hydration-mismatch' ), source: 'error', }, diff --git a/test/development/pages-dir/client-navigation/index.test.ts b/test/development/pages-dir/client-navigation/index.test.ts index 80b50b9195f41..f8a8ac9bce458 100644 --- a/test/development/pages-dir/client-navigation/index.test.ts +++ b/test/development/pages-dir/client-navigation/index.test.ts @@ -13,6 +13,8 @@ import webdriver from 'next-webdriver' import path from 'path' import { nextTestSetup } from 'e2e-utils' +const isReact18 = parseInt(process.env.NEXT_TEST_REACT_VERSION) === 18 + describe('Client Navigation', () => { const { next } = nextTestSetup({ files: path.join(__dirname, 'fixture'), @@ -1669,19 +1671,19 @@ describe.each([[false], [true]])( expect( Number(await browser.eval('window.__test_async_executions')) ).toBe( - strictNextHead + strictNextHead || isReact18 ? 1 : // is floated before