Skip to content

Commit 1cb6faa

Browse files
authored
Extend support of Pages router to React 18 (#70219)
1 parent 090dc45 commit 1cb6faa

File tree

26 files changed

+1110
-436
lines changed

26 files changed

+1110
-436
lines changed

.github/workflows/build_and_test.yml

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,12 @@ jobs:
202202
fail-fast: false
203203
matrix:
204204
group: [1/5, 2/5, 3/5, 4/5, 5/5]
205+
# Empty value uses default
206+
react: ['', '18.3.1']
205207
uses: ./.github/workflows/build_reusable.yml
206208
with:
207-
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}
208-
stepName: 'test-turbopack-dev-${{ matrix.group }}'
209+
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}
210+
stepName: 'test-turbopack-dev-react-${{ matrix.react }}-${{ matrix.group }}'
209211
secrets: inherit
210212

211213
test-turbopack-integration:
@@ -217,11 +219,13 @@ jobs:
217219
fail-fast: false
218220
matrix:
219221
group: [1/5, 2/5, 3/5, 4/5, 5/5]
222+
# Empty value uses default
223+
react: ['']
220224
uses: ./.github/workflows/build_reusable.yml
221225
with:
222226
nodeVersion: 18.18.2
223-
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
224-
stepName: 'test-turbopack-integration-${{ matrix.group }}'
227+
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
228+
stepName: 'test-turbopack-integration-react-${{ matrix.react }}-${{ matrix.group }}'
225229
secrets: inherit
226230

227231
test-turbopack-production:
@@ -233,11 +237,17 @@ jobs:
233237
fail-fast: false
234238
matrix:
235239
group: [1/5, 2/5, 3/5, 4/5, 5/5]
240+
# Empty value uses default
241+
# TODO: Run with React 18.
242+
# Integration tests use the installed React version in next/package.json.include:
243+
# We can't easily switch like we do for e2e tests.
244+
# Skipping this dimensions until we can figure out a way to test multiple React versions.
245+
react: ['', '18.3.1']
236246
uses: ./.github/workflows/build_reusable.yml
237247
with:
238248
nodeVersion: 18.18.2
239-
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
240-
stepName: 'test-turbopack-production-${{ matrix.group }}'
249+
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
250+
stepName: 'test-turbopack-production-react-${{ matrix.react }}-${{ matrix.group }}'
241251
secrets: inherit
242252

243253
test-turbopack-production-integration:
@@ -362,10 +372,12 @@ jobs:
362372
fail-fast: false
363373
matrix:
364374
group: [1/4, 2/4, 3/4, 4/4]
375+
# Empty value uses default
376+
react: ['', '18.3.1']
365377
uses: ./.github/workflows/build_reusable.yml
366378
with:
367-
afterBuild: NEXT_TEST_MODE=dev node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type development
368-
stepName: 'test-dev-${{ matrix.group }}'
379+
afterBuild: NEXT_TEST_MODE=dev NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type development
380+
stepName: 'test-dev-react-${{ matrix.react }}-${{ matrix.group }}'
369381
secrets: inherit
370382

371383
test-prod:
@@ -377,10 +389,12 @@ jobs:
377389
fail-fast: false
378390
matrix:
379391
group: [1/5, 2/5, 3/5, 4/5, 5/5]
392+
# Empty value uses default
393+
react: ['', '18.3.1']
380394
uses: ./.github/workflows/build_reusable.yml
381395
with:
382-
afterBuild: NEXT_TEST_MODE=start node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type production
383-
stepName: 'test-prod-${{ matrix.group }}'
396+
afterBuild: NEXT_TEST_MODE=start NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type production
397+
stepName: 'test-prod-react-${{ matrix.react }}-${{ matrix.group }}'
384398
secrets: inherit
385399

386400
test-integration:
@@ -404,11 +418,17 @@ jobs:
404418
- 10/12
405419
- 11/12
406420
- 12/12
421+
# Empty value uses default
422+
# TODO: Run with React 18.
423+
# Integration tests use the installed React version in next/package.json.include:
424+
# We can't easily switch like we do for e2e tests.
425+
# Skipping this dimensions until we can figure out a way to test multiple React versions.
426+
react: ['']
407427
uses: ./.github/workflows/build_reusable.yml
408428
with:
409429
nodeVersion: 18.18.2
410-
afterBuild: node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type integration
411-
stepName: 'test-integration-${{ matrix.group }}'
430+
afterBuild: NEXT_TEST_REACT_VERSION="${{ matrix.react }}" node run-tests.js --timings -g ${{ matrix.group }} -c ${TEST_CONCURRENCY} --type integration
431+
stepName: 'test-integration-${{ matrix.group }}-react-${{ matrix.react }}'
412432
secrets: inherit
413433

414434
test-firefox-safari:

crates/next-core/src/next_import_map.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,15 @@ async fn insert_next_server_special_aliases(
567567
external_esm_if_node(project_path, "next/dist/compiled/@vercel/og/index.node.js"),
568568
);
569569

570+
import_map.insert_exact_alias(
571+
"next/dist/server/ReactDOMServerPages",
572+
ImportMapping::Alternatives(vec![
573+
request_to_import_mapping(project_path, "react-dom/server.edge"),
574+
request_to_import_mapping(project_path, "react-dom/server.browser"),
575+
])
576+
.cell(),
577+
);
578+
570579
import_map.insert_exact_alias(
571580
"@opentelemetry/api",
572581
// It needs to prefer the local version of @opentelemetry/api

packages/next/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,8 @@
107107
"@opentelemetry/api": "^1.1.0",
108108
"@playwright/test": "^1.41.2",
109109
"babel-plugin-react-compiler": "*",
110-
"react": "19.0.0-rc-5d19e1c8-20240923",
111-
"react-dom": "19.0.0-rc-5d19e1c8-20240923",
110+
"react": "^18.2.0 || 19.0.0-rc-5d19e1c8-20240923",
111+
"react-dom": "^18.2.0 || 19.0.0-rc-5d19e1c8-20240923",
112112
"sass": "^1.3.0"
113113
},
114114
"peerDependenciesMeta": {

packages/next/src/build/create-compiler-aliases.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from 'path'
2+
import * as React from 'react'
23
import {
34
DOT_NEXT_ALIAS,
45
PAGES_DIR_ALIAS,
@@ -21,6 +22,8 @@ interface CompilerAliases {
2122
[alias: string]: string | string[]
2223
}
2324

25+
const isReact19 = typeof React.use === 'function'
26+
2427
export function createWebpackAliases({
2528
distDir,
2629
isClient,
@@ -90,6 +93,12 @@ export function createWebpackAliases({
9093
return {
9194
'@vercel/og$': 'next/dist/server/og/image-response',
9295

96+
// Avoid bundling both entrypoints in React 19 when we just need one.
97+
// Also avoids bundler warnings in React 18 where react-dom/server.edge doesn't exist.
98+
'next/dist/server/ReactDOMServerPages': isReact19
99+
? 'react-dom/server.edge'
100+
: 'react-dom/server.browser',
101+
93102
// Alias next/dist imports to next/dist/esm assets,
94103
// let this alias hit before `next` alias.
95104
...(isEdgeServer

packages/next/src/build/webpack-config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,8 @@ const NEXT_PROJECT_ROOT_DIST_CLIENT = path.join(
107107
'client'
108108
)
109109

110-
if (parseInt(React.version) < 19) {
111-
throw new Error('Next.js requires react >= 19.0.0 to be installed.')
110+
if (parseInt(React.version) < 18) {
111+
throw new Error('Next.js requires react >= 18.2.0 to be installed.')
112112
}
113113

114114
export const babelIncludeRegexes: RegExp[] = [

packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export function PseudoHtmlDiff({
6666
firstContent: string
6767
secondContent: string
6868
reactOutputComponentDiff: string | undefined
69-
hydrationMismatchType: 'tag' | 'text'
69+
hydrationMismatchType: 'tag' | 'text' | 'text-in-tag'
7070
} & React.HTMLAttributes<HTMLPreElement>) {
7171
const isHtmlTagsWarning = hydrationMismatchType === 'tag'
7272
const isReactHydrationDiff = !!reactOutputComponentDiff

packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,62 @@ export const hydrationErrorState: HydrationErrorState = {}
1717

1818
// https://github.com/facebook/react/blob/main/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js used as a reference
1919
const htmlTagsWarnings = new Set([
20-
'In HTML, %s cannot be a child of <%s>.%s\nThis will cause a hydration error.%s',
21-
'In HTML, %s cannot be a descendant of <%s>.\nThis will cause a hydration error.%s',
22-
'In HTML, text nodes cannot be a child of <%s>.\nThis will cause a hydration error.',
23-
"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.",
20+
'Warning: In HTML, %s cannot be a child of <%s>.%s\nThis will cause a hydration error.%s',
21+
'Warning: In HTML, %s cannot be a descendant of <%s>.\nThis will cause a hydration error.%s',
22+
'Warning: In HTML, text nodes cannot be a child of <%s>.\nThis will cause a hydration error.',
23+
"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.",
24+
'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
25+
'Warning: Did not expect server HTML to contain a <%s> in <%s>.%s',
2426
])
27+
const textAndTagsMismatchWarnings = new Set([
28+
'Warning: Expected server HTML to contain a matching text node for "%s" in <%s>.%s',
29+
'Warning: Did not expect server HTML to contain the text node "%s" in <%s>.%s',
30+
])
31+
const textMismatchWarning =
32+
'Warning: Text content did not match. Server: "%s" Client: "%s"%s'
33+
34+
export const getHydrationWarningType = (
35+
message: NullableText
36+
): 'tag' | 'text' | 'text-in-tag' => {
37+
if (typeof message !== 'string') {
38+
// TODO: Doesn't make sense to treat no message as a hydration error message.
39+
// We should bail out somewhere earlier.
40+
return 'text'
41+
}
42+
43+
const normalizedMessage = message.startsWith('Warning: ')
44+
? message
45+
: `Warning: ${message}`
46+
47+
if (isHtmlTagsWarning(normalizedMessage)) return 'tag'
48+
if (isTextInTagsMismatchWarning(normalizedMessage)) return 'text-in-tag'
2549

26-
export const getHydrationWarningType = (msg: NullableText): 'tag' | 'text' => {
27-
if (isHtmlTagsWarning(msg)) return 'tag'
2850
return 'text'
2951
}
3052

31-
const isHtmlTagsWarning = (msg: NullableText) =>
32-
Boolean(msg && htmlTagsWarnings.has(msg))
53+
const isHtmlTagsWarning = (message: string) => htmlTagsWarnings.has(message)
3354

34-
const isKnownHydrationWarning = (msg: NullableText) => isHtmlTagsWarning(msg)
55+
const isTextMismatchWarning = (message: string) =>
56+
textMismatchWarning === message
57+
const isTextInTagsMismatchWarning = (msg: string) =>
58+
textAndTagsMismatchWarnings.has(msg)
59+
60+
const isKnownHydrationWarning = (message: NullableText) => {
61+
if (typeof message !== 'string') {
62+
return false
63+
}
64+
// React 18 has the `Warning: ` prefix.
65+
// React 19 does not.
66+
const normalizedMessage = message.startsWith('Warning: ')
67+
? message
68+
: `Warning: ${message}`
69+
70+
return (
71+
isHtmlTagsWarning(normalizedMessage) ||
72+
isTextInTagsMismatchWarning(normalizedMessage) ||
73+
isTextMismatchWarning(normalizedMessage)
74+
)
75+
}
3576

3677
export const getReactHydrationDiffSegments = (msg: NullableText) => {
3778
if (msg) {

packages/next/src/client/legacy/image.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import React, {
99
useState,
1010
type JSX,
1111
} from 'react'
12+
import * as ReactDOM from 'react-dom'
13+
import Head from '../../shared/lib/head'
1214
import {
1315
imageConfigDefault,
1416
VALID_LOADERS,
@@ -26,6 +28,8 @@ function normalizeSrc(src: string): string {
2628
return src[0] === '/' ? src.slice(1) : src
2729
}
2830

31+
const supportsFloat = typeof ReactDOM.preload === 'function'
32+
2933
const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete
3034
const loadedImageURLs = new Set<string>()
3135
const allImgs = new Map<
@@ -978,6 +982,20 @@ export default function Image({
978982
}
979983
}
980984

985+
const linkProps:
986+
| React.DetailedHTMLProps<
987+
React.LinkHTMLAttributes<HTMLLinkElement>,
988+
HTMLLinkElement
989+
>
990+
| undefined = supportsFloat
991+
? undefined
992+
: {
993+
imageSrcSet: imgAttributes.srcSet,
994+
imageSizes: imgAttributes.sizes,
995+
crossOrigin: rest.crossOrigin,
996+
referrerPolicy: rest.referrerPolicy,
997+
}
998+
981999
const useLayoutEffect =
9821000
typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect
9831001
const onLoadingCompleteRef = useRef(onLoadingComplete)
@@ -1044,6 +1062,27 @@ export default function Image({
10441062
) : null}
10451063
<ImageElement {...imgElementArgs} />
10461064
</span>
1065+
{!supportsFloat && priority ? (
1066+
// Note how we omit the `href` attribute, as it would only be relevant
1067+
// for browsers that do not support `imagesrcset`, and in those cases
1068+
// it would likely cause the incorrect image to be preloaded.
1069+
//
1070+
// https://html.spec.whatwg.org/multipage/semantics.html#attr-link-imagesrcset
1071+
<Head>
1072+
<link
1073+
key={
1074+
'__nimg-' +
1075+
imgAttributes.src +
1076+
imgAttributes.srcSet +
1077+
imgAttributes.sizes
1078+
}
1079+
rel="preload"
1080+
as="image"
1081+
href={imgAttributes.srcSet ? undefined : imgAttributes.src}
1082+
{...linkProps}
1083+
/>
1084+
</Head>
1085+
) : null}
10471086
</>
10481087
)
10491088
}

packages/next/src/client/use-merged-ref.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,34 @@
1-
import { useMemo, type Ref } from 'react'
1+
import { useMemo, useRef, type Ref } from 'react'
22

3+
// This is a compatibility hook to support React 18 and 19 refs.
4+
// In 19, a cleanup function from refs may be returned.
5+
// In 18, returning a cleanup function creates a warning.
6+
// Since we take userspace refs, we don't know ahead of time if a cleanup function will be returned.
7+
// This implements cleanup functions with the old behavior in 18.
8+
// We know refs are always called alternating with `null` and then `T`.
9+
// So a call with `null` means we need to call the previous cleanup functions.
310
export function useMergedRef<TElement>(
411
refA: Ref<TElement>,
512
refB: Ref<TElement>
613
): Ref<TElement> {
7-
return useMemo(() => mergeRefs(refA, refB), [refA, refB])
8-
}
14+
const cleanupA = useRef<() => void>(() => {})
15+
const cleanupB = useRef<() => void>(() => {})
916

10-
export function mergeRefs<TElement>(
11-
refA: Ref<TElement>,
12-
refB: Ref<TElement>
13-
): Ref<TElement> {
14-
if (!refA || !refB) {
15-
return refA || refB
16-
}
17-
18-
return (current: TElement) => {
19-
const cleanupA = applyRef(refA, current)
20-
const cleanupB = applyRef(refB, current)
17+
return useMemo(() => {
18+
if (!refA || !refB) {
19+
return refA || refB
20+
}
2121

22-
return () => {
23-
cleanupA()
24-
cleanupB()
22+
return (current: TElement | null): void => {
23+
if (current === null) {
24+
cleanupA.current()
25+
cleanupB.current()
26+
} else {
27+
cleanupA.current = applyRef(refA, current)
28+
cleanupB.current = applyRef(refB, current)
29+
}
2530
}
26-
}
31+
}, [refA, refB])
2732
}
2833

2934
function applyRef<TElement>(
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from 'react-dom/server.edge'
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
let ReactDOMServer
2+
3+
try {
4+
ReactDOMServer = require('react-dom/server.edge')
5+
} catch (error) {
6+
if (
7+
error.code !== 'MODULE_NOT_FOUND' &&
8+
error.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED'
9+
) {
10+
throw error
11+
}
12+
// In React versions without react-dom/server.edge, the browser build works in Node.js.
13+
// The Node.js build does not support renderToReadableStream.
14+
ReactDOMServer = require('react-dom/server.browser')
15+
}
16+
17+
module.exports = ReactDOMServer

0 commit comments

Comments
 (0)