diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 46c28490a56db..fa4019ab1d848 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -202,10 +202,12 @@ jobs: fail-fast: false matrix: group: [1/5, 2/5, 3/5, 4/5, 5/5] + # Empty value uses default + react: ['', '18.3.1'] uses: ./.github/workflows/build_reusable.yml with: - afterBuild: RUST_BACKTRACE=0 NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/turbopack-dev-tests-manifest.json" TURBOPACK=1 TURBOPACK_DEV=1 NEXT_E2E_TEST_TIMEOUT=240000 NEXT_TEST_MODE=dev node run-tests.js --test-pattern '^(test\/(development|e2e))/.*\.test\.(js|jsx|ts|tsx)$' --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} - stepName: 'test-turbopack-dev-${{ matrix.group }}' + afterBuild: RUST_BACKTRACE=0 NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/turbopack-dev-tests-manifest.json" TURBOPACK=1 TURBOPACK_DEV=1 NEXT_E2E_TEST_TIMEOUT=240000 NEXT_TEST_MODE=dev NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --test-pattern '^(test\/(development|e2e))/.*\.test\.(js|jsx|ts|tsx)$' --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} + stepName: 'test-turbopack-dev-react-${{ matrix.react }}-${{ matrix.group }}' secrets: inherit test-turbopack-integration: @@ -217,11 +219,13 @@ jobs: fail-fast: false matrix: group: [1/5, 2/5, 3/5, 4/5, 5/5] + # Empty value uses default + react: [''] uses: ./.github/workflows/build_reusable.yml with: nodeVersion: 18.18.2 - afterBuild: RUST_BACKTRACE=0 NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/turbopack-dev-tests-manifest.json" TURBOPACK=1 TURBOPACK_DEV=1 node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type integration - stepName: 'test-turbopack-integration-${{ matrix.group }}' + afterBuild: RUST_BACKTRACE=0 NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/turbopack-dev-tests-manifest.json" TURBOPACK=1 TURBOPACK_DEV=1 NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type integration + stepName: 'test-turbopack-integration-react-${{ matrix.react }}-${{ matrix.group }}' secrets: inherit test-turbopack-production: @@ -233,11 +237,17 @@ jobs: fail-fast: false matrix: group: [1/5, 2/5, 3/5, 4/5, 5/5] + # Empty value uses default + # TODO: Run with React 18. + # Integration tests use the installed React version in next/package.json.include: + # We can't easily switch like we do for e2e tests. + # Skipping this dimensions until we can figure out a way to test multiple React versions. + react: ['', '18.3.1'] uses: ./.github/workflows/build_reusable.yml with: nodeVersion: 18.18.2 - afterBuild: RUST_BACKTRACE=0 NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/turbopack-build-tests-manifest.json" TURBOPACK=1 TURBOPACK_BUILD=1 NEXT_TEST_MODE=start node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type production - stepName: 'test-turbopack-production-${{ matrix.group }}' + afterBuild: RUST_BACKTRACE=0 NEXT_EXTERNAL_TESTS_FILTERS="$(pwd)/test/turbopack-build-tests-manifest.json" TURBOPACK=1 TURBOPACK_BUILD=1 NEXT_TEST_MODE=start NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type production + stepName: 'test-turbopack-production-react-${{ matrix.react }}-${{ matrix.group }}' secrets: inherit test-turbopack-production-integration: @@ -362,10 +372,12 @@ jobs: fail-fast: false matrix: group: [1/4, 2/4, 3/4, 4/4] + # Empty value uses default + react: ['', '18.3.1'] uses: ./.github/workflows/build_reusable.yml with: - afterBuild: NEXT_TEST_MODE=dev node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type development - stepName: 'test-dev-${{ matrix.group }}' + afterBuild: NEXT_TEST_MODE=dev NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type development + stepName: 'test-dev-react-${{ matrix.react }}-${{ matrix.group }}' secrets: inherit test-prod: @@ -377,10 +389,12 @@ jobs: fail-fast: false matrix: group: [1/5, 2/5, 3/5, 4/5, 5/5] + # Empty value uses default + react: ['', '18.3.1'] uses: ./.github/workflows/build_reusable.yml with: - afterBuild: NEXT_TEST_MODE=start node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type production - stepName: 'test-prod-${{ matrix.group }}' + afterBuild: NEXT_TEST_MODE=start NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type production + stepName: 'test-prod-react-${{ matrix.react }}-${{ matrix.group }}' secrets: inherit test-integration: @@ -404,11 +418,17 @@ jobs: - 10/12 - 11/12 - 12/12 + # Empty value uses default + # TODO: Run with React 18. + # Integration tests use the installed React version in next/package.json.include: + # We can't easily switch like we do for e2e tests. + # Skipping this dimensions until we can figure out a way to test multiple React versions. + react: [''] uses: ./.github/workflows/build_reusable.yml with: nodeVersion: 18.18.2 - afterBuild: node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type integration - stepName: 'test-integration-${{ matrix.group }}' + afterBuild: NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type integration + stepName: 'test-integration-${{ matrix.group }}-react-${{ matrix.react }}' secrets: inherit test-firefox-safari: diff --git a/crates/next-core/src/next_import_map.rs b/crates/next-core/src/next_import_map.rs index 5cef244e1df1a..d7c4739868396 100644 --- a/crates/next-core/src/next_import_map.rs +++ b/crates/next-core/src/next_import_map.rs @@ -567,6 +567,15 @@ async fn insert_next_server_special_aliases( external_esm_if_node(project_path, "next/dist/compiled/@vercel/og/index.node.js"), ); + import_map.insert_exact_alias( + "next/dist/server/ReactDOMServerPages", + ImportMapping::Alternatives(vec![ + request_to_import_mapping(project_path, "react-dom/server.edge"), + request_to_import_mapping(project_path, "react-dom/server.browser"), + ]) + .cell(), + ); + import_map.insert_exact_alias( "@opentelemetry/api", // It needs to prefer the local version of @opentelemetry/api diff --git a/packages/next/package.json b/packages/next/package.json index 9b169cb48ddc7..a314d0fbf15d2 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -107,8 +107,8 @@ "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", - "react": "19.0.0-rc-5d19e1c8-20240923", - "react-dom": "19.0.0-rc-5d19e1c8-20240923", + "react": "^18.2.0 || 19.0.0-rc-5d19e1c8-20240923", + "react-dom": "^18.2.0 || 19.0.0-rc-5d19e1c8-20240923", "sass": "^1.3.0" }, "peerDependenciesMeta": { diff --git a/packages/next/src/build/create-compiler-aliases.ts b/packages/next/src/build/create-compiler-aliases.ts index 49788fde474d2..27f6d454616bf 100644 --- a/packages/next/src/build/create-compiler-aliases.ts +++ b/packages/next/src/build/create-compiler-aliases.ts @@ -1,4 +1,5 @@ import path from 'path' +import * as React from 'react' import { DOT_NEXT_ALIAS, PAGES_DIR_ALIAS, @@ -21,6 +22,8 @@ interface CompilerAliases { [alias: string]: string | string[] } +const isReact19 = typeof React.use === 'function' + export function createWebpackAliases({ distDir, isClient, @@ -90,6 +93,12 @@ export function createWebpackAliases({ return { '@vercel/og$': 'next/dist/server/og/image-response', + // Avoid bundling both entrypoints in React 19 when we just need one. + // Also avoids bundler warnings in React 18 where react-dom/server.edge doesn't exist. + 'next/dist/server/ReactDOMServerPages': isReact19 + ? 'react-dom/server.edge' + : 'react-dom/server.browser', + // Alias next/dist imports to next/dist/esm assets, // let this alias hit before `next` alias. ...(isEdgeServer diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 56c8349cfc07d..5dee552b02fa9 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/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx index 6b2c858d6f346..58b2e2d199665 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx @@ -66,7 +66,7 @@ export function PseudoHtmlDiff({ firstContent: string secondContent: string reactOutputComponentDiff: string | undefined - hydrationMismatchType: 'tag' | 'text' + hydrationMismatchType: 'tag' | 'text' | 'text-in-tag' } & React.HTMLAttributes) { const isHtmlTagsWarning = hydrationMismatchType === 'tag' const isReactHydrationDiff = !!reactOutputComponentDiff diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts index f99a6e41c55d0..3ca474eeedb1a 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts @@ -17,21 +17,62 @@ export const hydrationErrorState: HydrationErrorState = {} // https://github.com/facebook/react/blob/main/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js used as a reference const htmlTagsWarnings = new Set([ - 'In HTML, %s cannot be a child of <%s>.%s\nThis will cause a hydration error.%s', - 'In HTML, %s cannot be a descendant of <%s>.\nThis will cause a hydration error.%s', - 'In HTML, text nodes cannot be a child of <%s>.\nThis will cause a hydration error.', - "In HTML, whitespace text nodes cannot be a child of <%s>. Make sure you don't have any extra whitespace between tags on each line of your source code.\nThis will cause a hydration error.", + 'Warning: In HTML, %s cannot be a child of <%s>.%s\nThis will cause a hydration error.%s', + 'Warning: In HTML, %s cannot be a descendant of <%s>.\nThis will cause a hydration error.%s', + 'Warning: In HTML, text nodes cannot be a child of <%s>.\nThis will cause a hydration error.', + "Warning: In HTML, whitespace text nodes cannot be a child of <%s>. Make sure you don't have any extra whitespace between tags on each line of your source code.\nThis will cause a hydration error.", + 'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s', + 'Warning: Did not expect server HTML to contain a <%s> in <%s>.%s', ]) +const textAndTagsMismatchWarnings = new Set([ + 'Warning: Expected server HTML to contain a matching text node for "%s" in <%s>.%s', + 'Warning: Did not expect server HTML to contain the text node "%s" in <%s>.%s', +]) +const textMismatchWarning = + 'Warning: Text content did not match. Server: "%s" Client: "%s"%s' + +export const getHydrationWarningType = ( + message: NullableText +): 'tag' | 'text' | 'text-in-tag' => { + if (typeof message !== 'string') { + // TODO: Doesn't make sense to treat no message as a hydration error message. + // We should bail out somewhere earlier. + return 'text' + } + + const normalizedMessage = message.startsWith('Warning: ') + ? message + : `Warning: ${message}` + + if (isHtmlTagsWarning(normalizedMessage)) return 'tag' + if (isTextInTagsMismatchWarning(normalizedMessage)) return 'text-in-tag' -export const getHydrationWarningType = (msg: NullableText): 'tag' | 'text' => { - if (isHtmlTagsWarning(msg)) return 'tag' return 'text' } -const isHtmlTagsWarning = (msg: NullableText) => - Boolean(msg && htmlTagsWarnings.has(msg)) +const isHtmlTagsWarning = (message: string) => htmlTagsWarnings.has(message) -const isKnownHydrationWarning = (msg: NullableText) => isHtmlTagsWarning(msg) +const isTextMismatchWarning = (message: string) => + textMismatchWarning === message +const isTextInTagsMismatchWarning = (msg: string) => + textAndTagsMismatchWarnings.has(msg) + +const isKnownHydrationWarning = (message: NullableText) => { + if (typeof message !== 'string') { + return false + } + // React 18 has the `Warning: ` prefix. + // React 19 does not. + const normalizedMessage = message.startsWith('Warning: ') + ? message + : `Warning: ${message}` + + return ( + isHtmlTagsWarning(normalizedMessage) || + isTextInTagsMismatchWarning(normalizedMessage) || + isTextMismatchWarning(normalizedMessage) + ) +} export const getReactHydrationDiffSegments = (msg: NullableText) => { if (msg) { 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/ReactDOMServerPages.d.ts b/packages/next/src/server/ReactDOMServerPages.d.ts new file mode 100644 index 0000000000000..4d4f000bdf487 --- /dev/null +++ b/packages/next/src/server/ReactDOMServerPages.d.ts @@ -0,0 +1 @@ +export * from 'react-dom/server.edge' diff --git a/packages/next/src/server/ReactDOMServerPages.js b/packages/next/src/server/ReactDOMServerPages.js new file mode 100644 index 0000000000000..1fa8fef05963e --- /dev/null +++ b/packages/next/src/server/ReactDOMServerPages.js @@ -0,0 +1,17 @@ +let ReactDOMServer + +try { + ReactDOMServer = require('react-dom/server.edge') +} catch (error) { + if ( + error.code !== 'MODULE_NOT_FOUND' && + error.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED' + ) { + throw error + } + // In React versions without react-dom/server.edge, the browser build works in Node.js. + // The Node.js build does not support renderToReadableStream. + ReactDOMServer = require('react-dom/server.browser') +} + +module.exports = ReactDOMServer diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index 2d3433e2e9922..ae913fb6c3a64 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 ReactDOMServerPages from 'next/dist/server/ReactDOMServerPages' import { StyleRegistry, createStyleRegistry } from 'styled-jsx' import { GSP_NO_RETURNED_VALUE, @@ -127,7 +127,7 @@ function noRouter() { } async function renderToString(element: React.ReactElement) { - const renderStream = await ReactDOMServerEdge.renderToReadableStream(element) + const renderStream = await ReactDOMServerPages.renderToReadableStream(element) await renderStream.allReady return streamToString(renderStream) } @@ -1326,7 +1326,7 @@ export async function renderToHTMLImpl( ) => { const content = renderContent(EnhancedApp, EnhancedComponent) return await renderToInitialFizzStream({ - ReactDOMServer: ReactDOMServerEdge, + ReactDOMServer: ReactDOMServerPages, element: content, }) } diff --git a/packages/next/types/$$compiled.internal.d.ts b/packages/next/types/$$compiled.internal.d.ts index 76832e459fb4e..6b0860ff32e1c 100644 --- a/packages/next/types/$$compiled.internal.d.ts +++ b/packages/next/types/$$compiled.internal.d.ts @@ -37,6 +37,10 @@ declare module 'VAR_USERLAND' declare module 'VAR_MODULE_DOCUMENT' declare module 'VAR_MODULE_APP' +declare module 'next/dist/server/ReactDOMServerPages' { + export * from 'react-dom/server.edge' +} + declare module 'next/dist/compiled/@napi-rs/triples' { export * from '@napi-rs/triples' } 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/scripts/sync-react.js b/scripts/sync-react.js index 2e4473a08b5bc..339207d028ce3 100644 --- a/scripts/sync-react.js +++ b/scripts/sync-react.js @@ -330,8 +330,9 @@ Or, run this command with no arguments to use the most recently published versio const nextjsPackageJson = JSON.parse( await fsp.readFile(nextjsPackageJsonPath, 'utf-8') ) - nextjsPackageJson.peerDependencies.react = `${newVersionStr}` - nextjsPackageJson.peerDependencies['react-dom'] = `${newVersionStr}` + nextjsPackageJson.peerDependencies.react = `^18.2.0 || ${newVersionStr}` + nextjsPackageJson.peerDependencies['react-dom'] = + `^18.2.0 || ${newVersionStr}` await fsp.writeFile( nextjsPackageJsonPath, JSON.stringify(nextjsPackageJson, null, 2) + diff --git a/test/development/acceptance/hydration-error.test.ts b/test/development/acceptance/hydration-error.test.ts index 99baf79bdd171..6046e2ea8e872 100644 --- a/test/development/acceptance/hydration-error.test.ts +++ b/test/development/acceptance/hydration-error.test.ts @@ -5,6 +5,7 @@ import path from 'path' import { outdent } from 'outdent' import { getRedboxTotalErrorCount } from 'next-test-utils' +const isReact18 = parseInt(process.env.NEXT_TEST_REACT_VERSION) === 18 // https://github.com/facebook/react/blob/main/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js used as a reference describe('Error overlay for hydration errors in Pages router', () => { @@ -39,10 +40,13 @@ describe('Error overlay for hydration errors in Pages router', () => { expect(logs).toEqual( expect.arrayContaining([ { - // TODO: Should probably link to https://nextjs.org/docs/messages/react-hydration-error instead. - message: expect.stringContaining( - 'https://react.dev/link/hydration-mismatch' - ), + message: isReact18 + ? // React 18 has no link in the hydration message + expect.stringContaining('Warning: Text content did not match.') + : // TODO: Should probably link to https://nextjs.org/docs/messages/react-hydration-error instead. + expect.stringContaining( + 'https://react.dev/link/hydration-mismatch' + ), source: 'error', }, ]) @@ -70,48 +74,85 @@ describe('Error overlay for hydration errors in Pages router', () => { ) await session.assertHasRedbox() - expect(await getRedboxTotalErrorCount(browser)).toBe(1) + expect(await getRedboxTotalErrorCount(browser)).toBe(isReact18 ? 2 : 1) - expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` - "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used - See more info here: https://nextjs.org/docs/messages/react-hydration-error" - `) - - expect(await session.getRedboxDescriptionWarning()).toMatchInlineSnapshot(` - "- A server/client branch \`if (typeof window !== 'undefined')\`. - - Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called. - - Date formatting in a user's locale which doesn't match the server. - - External changing data without sending a snapshot of it along with the HTML. - - Invalid HTML tag nesting. + if (isReact18) { + expect(await session.getRedboxDescription()).toMatchInlineSnapshot( + `"Text content did not match. Server: "server" Client: "client""` + ) + } else { + expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` + "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used + See more info here: https://nextjs.org/docs/messages/react-hydration-error" + `) + } - It can also happen if the client has a browser extension installed which messes with the HTML before React loaded." - `) + if (isReact18) { + expect(await session.getRedboxDescriptionWarning()).toMatchInlineSnapshot( + `undefined` + ) + } else { + expect(await session.getRedboxDescriptionWarning()) + .toMatchInlineSnapshot(` + "- A server/client branch \`if (typeof window !== 'undefined')\`. + - Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called. + - Date formatting in a user's locale which doesn't match the server. + - External changing data without sending a snapshot of it along with the HTML. + - Invalid HTML tag nesting. + + It can also happen if the client has a browser extension installed which messes with the HTML before React loaded." + `) + } const pseudoHtml = await session.getRedboxComponentStack() if (isTurbopack) { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... - - - - - - - -
-
- + client - - server" - `) + if (isReact18) { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... + + + + +
+
+ "server" + "client"" + `) + } else { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... + + + + + + + +
+
+ + client + - server" + `) + } } else { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... -
-
- + client - - server" - `) + if (isReact18) { + expect(pseudoHtml).toMatchInlineSnapshot(` + " +
+
+ "server" + "client"" + `) + } else { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... +
+
+ + client + - server" + `) + } } await session.patch( @@ -156,36 +197,63 @@ describe('Error overlay for hydration errors in Pages router', () => { ) await session.assertHasRedbox() - expect(await getRedboxTotalErrorCount(browser)).toBe(1) + expect(await getRedboxTotalErrorCount(browser)).toBe(isReact18 ? 3 : 1) const pseudoHtml = await session.getRedboxComponentStack() if (isTurbopack) { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... - - - - - - - -
- ... - +
" - `) + if (isReact18) { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... + +
+ ^^^^^ +
+ ^^^^^^" + `) + } else { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... + + + + + + + +
+ ... + +
" + `) + } } else { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... -
- ... - +
" - `) + if (isReact18) { + expect(pseudoHtml).toMatchInlineSnapshot(` + " +
+ ^^^^^ +
+ ^^^^^^" + `) + } else { + expect(pseudoHtml).toMatchInlineSnapshot(` + "... +
+ ... + +
" + `) + } } - expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` - "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used - See more info here: https://nextjs.org/docs/messages/react-hydration-error" - `) + if (isReact18) { + expect(await session.getRedboxDescription()).toMatchInlineSnapshot( + `"Expected server HTML to contain a matching
in
."` + ) + } else { + expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` + "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used + See more info here: https://nextjs.org/docs/messages/react-hydration-error" + `) + } await cleanup() }) @@ -213,36 +281,65 @@ describe('Error overlay for hydration errors in Pages router', () => { ) await session.assertHasRedbox() - expect(await getRedboxTotalErrorCount(browser)).toBe(1) + expect(await getRedboxTotalErrorCount(browser)).toBe(isReact18 ? 3 : 1) const pseudoHtml = await session.getRedboxComponentStack() if (isTurbopack) { - expect(pseudoHtml).toMatchInlineSnapshot(` - "... - - - - - - - -
- + second - -