Skip to content

Commit 0f2845d

Browse files
eps1lonztanner
andauthored
Support React 18 in Pages Router (#69484)
Pages router (`/pages`) will continue to support React 18 not the React 19 RC. Current thinking is that we'll add support for React 19 in Pages Router once 19 is stable. This does not affect App Router (`/app`) which continues to use the latest React Canary (i.e. React 19). #65058 is required reading to understand the changes in this PR --------- Co-authored-by: Zack Tanner <[email protected]>
1 parent 644b6c6 commit 0f2845d

File tree

38 files changed

+581
-341
lines changed

38 files changed

+581
-341
lines changed

examples/reproduction-template/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
},
88
"dependencies": {
99
"next": "canary",
10-
"react": "19.0.0-rc-7771d3a7-20240827",
11-
"react-dom": "19.0.0-rc-7771d3a7-20240827"
10+
"react": "^18.2.0",
11+
"react-dom": "^18.2.0"
1212
},
1313
"devDependencies": {
1414
"@types/node": "20.12.12",

package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,10 +206,10 @@
206206
"pretty-bytes": "5.3.0",
207207
"pretty-ms": "7.0.0",
208208
"random-seed": "0.3.0",
209-
"react": "19.0.0-rc-7771d3a7-20240827",
209+
"react": "18.3.1",
210210
"react-17": "npm:[email protected]",
211211
"react-builtin": "npm:[email protected]",
212-
"react-dom": "19.0.0-rc-7771d3a7-20240827",
212+
"react-dom": "18.3.1",
213213
"react-dom-17": "npm:[email protected]",
214214
"react-dom-builtin": "npm:[email protected]",
215215
"react-dom-experimental-builtin": "npm:[email protected]",
@@ -269,10 +269,10 @@
269269
"@babel/traverse": "7.22.5",
270270
"@types/react": "npm:[email protected]",
271271
"@types/react-dom": "npm:[email protected]",
272-
"react": "19.0.0-rc-7771d3a7-20240827",
273-
"react-dom": "19.0.0-rc-7771d3a7-20240827",
274-
"react-is": "19.0.0-rc-7771d3a7-20240827",
275-
"scheduler": "0.25.0-rc-7771d3a7-20240827"
272+
"react": "18.3.1",
273+
"react-dom": "18.3.1",
274+
"react-is": "18.3.1",
275+
"scheduler": "0.23.2"
276276
},
277277
"patchedDependencies": {
278278

packages/create-next-app/templates/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { GetTemplateFileArgs, InstallTemplateArgs } from "./types";
1313

1414
// Do not rename or format. sync-react script relies on this line.
1515
// prettier-ignore
16-
const nextjsReactPeerVersion = "19.0.0-rc-7771d3a7-20240827";
16+
const nextjsReactPeerVersion = "^18.2.0";
1717

1818
/**
1919
* Get the file path for a given file in a template, e.g. "next.config.js".

packages/next/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@
108108
"@opentelemetry/api": "^1.1.0",
109109
"@playwright/test": "^1.41.2",
110110
"babel-plugin-react-compiler": "*",
111-
"react": "19.0.0-rc-7771d3a7-20240827",
112-
"react-dom": "19.0.0-rc-7771d3a7-20240827",
111+
"react": "^18.2.0",
112+
"react-dom": "^18.2.0",
113113
"sass": "^1.3.0"
114114
},
115115
"peerDependenciesMeta": {

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/Errors.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,6 @@ export function Errors({
238238
)
239239

240240
const errorDetails: HydrationErrorState = (error as any).details || {}
241-
const notes = errorDetails.notes || ''
242241
const [warningTemplate, serverContent, clientContent] =
243242
errorDetails.warning || [null, '', '']
244243

@@ -252,6 +251,7 @@ export function Errors({
252251
.replace(/^Warning: /, '')
253252
.replace(/^Error: /, '')
254253
: null
254+
const notes = isAppDir ? errorDetails.notes || '' : hydrationWarning
255255

256256
return (
257257
<Overlay>
@@ -307,7 +307,9 @@ export function Errors({
307307
{/* If there's hydration warning, skip displaying the error name */}
308308
{hydrationWarning ? '' : error.name + ': '}
309309
<HotlinkedText
310-
text={hydrationWarning || error.message}
310+
text={
311+
isAppDir ? hydrationWarning || error.message : error.message
312+
}
311313
matcher={isNextjsLink}
312314
/>
313315
</p>

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: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,42 @@ const htmlTagsWarnings = new Set([
2323
"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.",
2424
])
2525

26-
export const getHydrationWarningType = (msg: NullableText): 'tag' | 'text' => {
26+
// In React 18, the warning message is prefixed with "Warning: "
27+
const normalizeWarningMessage = (msg: string) => msg.replace(/^Warning: /, '')
28+
29+
// Note: React 18 only
30+
const textAndTagsMismatchWarnings = new Set([
31+
'Warning: Expected server HTML to contain a matching text node for "%s" in <%s>.%s',
32+
'Warning: Did not expect server HTML to contain the text node "%s" in <%s>.%s',
33+
])
34+
35+
// Note: React 18 only
36+
const textMismatchWarning =
37+
'Warning: Text content did not match. Server: "%s" Client: "%s"%s'
38+
39+
const isTextMismatchWarning = (msg: NullableText) => textMismatchWarning === msg
40+
const isTextInTagsMismatchWarning = (msg: NullableText) =>
41+
Boolean(msg && textAndTagsMismatchWarnings.has(msg))
42+
43+
export const getHydrationWarningType = (
44+
msg: NullableText
45+
): 'tag' | 'text' | 'text-in-tag' => {
2746
if (isHtmlTagsWarning(msg)) return 'tag'
2847
return 'text'
2948
}
3049

31-
const isHtmlTagsWarning = (msg: NullableText) =>
32-
Boolean(msg && htmlTagsWarnings.has(msg))
50+
const isHtmlTagsWarning = (msg: NullableText) => {
51+
if (msg && typeof msg === 'string') {
52+
return htmlTagsWarnings.has(normalizeWarningMessage(msg))
53+
}
54+
55+
return false
56+
}
3357

34-
const isKnownHydrationWarning = (msg: NullableText) => isHtmlTagsWarning(msg)
58+
const isKnownHydrationWarning = (msg: NullableText) =>
59+
isHtmlTagsWarning(msg) ||
60+
isTextInTagsMismatchWarning(msg) ||
61+
isTextMismatchWarning(msg)
3562

3663
export const getReactHydrationDiffSegments = (msg: NullableText) => {
3764
if (msg) {
@@ -51,14 +78,18 @@ export const getReactHydrationDiffSegments = (msg: NullableText) => {
5178
export function storeHydrationErrorStateFromConsoleArgs(...args: any[]) {
5279
const [msg, serverContent, clientContent, componentStack] = args
5380
if (isKnownHydrationWarning(msg)) {
54-
hydrationErrorState.warning = [
55-
// remove the last %s from the message
56-
msg,
57-
serverContent,
58-
clientContent,
59-
]
81+
hydrationErrorState.warning = [msg, serverContent, clientContent]
6082
hydrationErrorState.componentStack = componentStack
6183
hydrationErrorState.serverContent = serverContent
6284
hydrationErrorState.clientContent = clientContent
85+
86+
return [
87+
...args,
88+
// We tack on the hydration error message to the console.error message so that
89+
// it matches the error we display in the redbox overlay
90+
`\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error`,
91+
]
6392
}
93+
94+
return args
6495
}

packages/next/src/client/components/react-dev-overlay/pages/client.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,13 @@ function handleError(error: unknown) {
5252

5353
let origConsoleError = console.error
5454
function nextJsHandleConsoleError(...args: any[]) {
55+
// To support React 19, this will need to be updated as follows:
56+
// const error = process.env.NODE_ENV !== 'production' ? args[1] : args[0]
5557
// See https://github.com/facebook/react/blob/d50323eb845c5fde0d720cae888bf35dedd05506/packages/react-reconciler/src/ReactFiberErrorLogger.js#L78
56-
const error = process.env.NODE_ENV !== 'production' ? args[1] : args[0]
57-
storeHydrationErrorStateFromConsoleArgs(...args)
58+
const error = args[0]
59+
const errorArgs = storeHydrationErrorStateFromConsoleArgs(...args)
5860
handleError(error)
59-
origConsoleError.apply(window.console, args)
61+
origConsoleError.apply(window.console, errorArgs)
6062
}
6163

6264
function onUnhandledError(event: ErrorEvent) {

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>(

packages/next/src/compiled/unistore/unistore.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/next/src/server/render.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import type { Revalidate, SwrDelta } from './lib/revalidate'
4040
import type { COMPILER_NAMES } from '../shared/lib/constants'
4141

4242
import React, { type JSX } from 'react'
43-
import ReactDOMServerEdge from 'react-dom/server.edge'
43+
import ReactDOMServerBrowser from 'react-dom/server.browser'
4444
import { StyleRegistry, createStyleRegistry } from 'styled-jsx'
4545
import {
4646
GSP_NO_RETURNED_VALUE,
@@ -127,7 +127,8 @@ function noRouter() {
127127
}
128128

129129
async function renderToString(element: React.ReactElement) {
130-
const renderStream = await ReactDOMServerEdge.renderToReadableStream(element)
130+
const renderStream =
131+
await ReactDOMServerBrowser.renderToReadableStream(element)
131132
await renderStream.allReady
132133
return streamToString(renderStream)
133134
}
@@ -1322,7 +1323,7 @@ export async function renderToHTMLImpl(
13221323
) => {
13231324
const content = renderContent(EnhancedApp, EnhancedComponent)
13241325
return await renderToInitialFizzStream({
1325-
ReactDOMServer: ReactDOMServerEdge,
1326+
ReactDOMServer: ReactDOMServerBrowser,
13261327
element: content,
13271328
})
13281329
}

packages/next/types/react-dom.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ declare module 'react-dom/server.edge' {
7070
>
7171
}
7272

73+
declare module 'react-dom/server.browser' {
74+
export * from 'react-dom/server.edge'
75+
}
76+
7377
declare module 'react-dom/static.edge' {
7478
import type { JSX } from 'react'
7579
/**

packages/next/webpack.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const pagesExternals = [
1313
'react-dom/package.json',
1414
'react-dom/client',
1515
'react-dom/server',
16+
'react-dom/server.browser',
1617
'react-dom/server.edge',
1718
'react-server-dom-webpack/client',
1819
'react-server-dom-webpack/client.edge',

0 commit comments

Comments
 (0)