Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extend support of Pages router to React 18 #70219

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 32 additions & 12 deletions .github/workflows/build_and_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions crates/next-core/src/next_import_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
9 changes: 9 additions & 0 deletions packages/next/src/build/create-compiler-aliases.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from 'path'
import * as React from 'react'
import {
DOT_NEXT_ALIAS,
PAGES_DIR_ALIAS,
Expand All @@ -21,6 +22,8 @@ interface CompilerAliases {
[alias: string]: string | string[]
}

const isReact19 = typeof React.use === 'function'

export function createWebpackAliases({
distDir,
isClient,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLPreElement>) {
const isHtmlTagsWarning = hydrationMismatchType === 'tag'
const isReactHydrationDiff = !!reactOutputComponentDiff
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
huozhi marked this conversation as resolved.
Show resolved Hide resolved

return (
isHtmlTagsWarning(normalizedMessage) ||
isTextInTagsMismatchWarning(normalizedMessage) ||
isTextMismatchWarning(normalizedMessage)
)
}

export const getReactHydrationDiffSegments = (msg: NullableText) => {
if (msg) {
Expand Down
39 changes: 39 additions & 0 deletions packages/next/src/client/legacy/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string>()
const allImgs = new Map<
Expand Down Expand Up @@ -978,6 +982,20 @@ export default function Image({
}
}

const linkProps:
| React.DetailedHTMLProps<
React.LinkHTMLAttributes<HTMLLinkElement>,
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)
Expand Down Expand Up @@ -1044,6 +1062,27 @@ export default function Image({
) : null}
<ImageElement {...imgElementArgs} />
</span>
{!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
<Head>
<link
key={
'__nimg-' +
imgAttributes.src +
imgAttributes.srcSet +
imgAttributes.sizes
}
rel="preload"
as="image"
href={imgAttributes.srcSet ? undefined : imgAttributes.src}
{...linkProps}
/>
</Head>
) : null}
</>
)
}
41 changes: 23 additions & 18 deletions packages/next/src/client/use-merged-ref.ts
Original file line number Diff line number Diff line change
@@ -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<TElement>(
refA: Ref<TElement>,
refB: Ref<TElement>
): Ref<TElement> {
return useMemo(() => mergeRefs(refA, refB), [refA, refB])
}
const cleanupA = useRef<() => void>(() => {})
const cleanupB = useRef<() => void>(() => {})

export function mergeRefs<TElement>(
refA: Ref<TElement>,
refB: Ref<TElement>
): Ref<TElement> {
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<TElement>(
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/ReactDOMServerPages.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from 'react-dom/server.edge'
17 changes: 17 additions & 0 deletions packages/next/src/server/ReactDOMServerPages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
let ReactDOMServer

try {
ReactDOMServer = require('react-dom/server.edge')
eps1lon marked this conversation as resolved.
Show resolved Hide resolved
} 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
Loading
Loading