Skip to content

Commit

Permalink
Support React 18 in Pages Router (#69484)
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
eps1lon and ztanner authored Sep 9, 2024
1 parent 644b6c6 commit 0f2845d
Show file tree
Hide file tree
Showing 38 changed files with 581 additions and 341 deletions.
4 changes: 2 additions & 2 deletions examples/reproduction-template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
},
"dependencies": {
"next": "canary",
"react": "19.0.0-rc-7771d3a7-20240827",
"react-dom": "19.0.0-rc-7771d3a7-20240827"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/node": "20.12.12",
Expand Down
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,10 @@
"pretty-bytes": "5.3.0",
"pretty-ms": "7.0.0",
"random-seed": "0.3.0",
"react": "19.0.0-rc-7771d3a7-20240827",
"react": "18.3.1",
"react-17": "npm:[email protected]",
"react-builtin": "npm:[email protected]",
"react-dom": "19.0.0-rc-7771d3a7-20240827",
"react-dom": "18.3.1",
"react-dom-17": "npm:[email protected]",
"react-dom-builtin": "npm:[email protected]",
"react-dom-experimental-builtin": "npm:[email protected]",
Expand Down Expand Up @@ -269,10 +269,10 @@
"@babel/traverse": "7.22.5",
"@types/react": "npm:[email protected]",
"@types/react-dom": "npm:[email protected]",
"react": "19.0.0-rc-7771d3a7-20240827",
"react-dom": "19.0.0-rc-7771d3a7-20240827",
"react-is": "19.0.0-rc-7771d3a7-20240827",
"scheduler": "0.25.0-rc-7771d3a7-20240827"
"react": "18.3.1",
"react-dom": "18.3.1",
"react-is": "18.3.1",
"scheduler": "0.23.2"
},
"patchedDependencies": {
"[email protected]": "patches/[email protected]"
Expand Down
2 changes: 1 addition & 1 deletion packages/create-next-app/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { GetTemplateFileArgs, InstallTemplateArgs } from "./types";

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

/**
* Get the file path for a given file in a template, e.g. "next.config.js".
Expand Down
4 changes: 2 additions & 2 deletions packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2",
"babel-plugin-react-compiler": "*",
"react": "19.0.0-rc-7771d3a7-20240827",
"react-dom": "19.0.0-rc-7771d3a7-20240827",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
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 @@ -238,7 +238,6 @@ export function Errors({
)

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

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

return (
<Overlay>
Expand Down Expand Up @@ -307,7 +307,9 @@ export function Errors({
{/* If there's hydration warning, skip displaying the error name */}
{hydrationWarning ? '' : error.name + ': '}
<HotlinkedText
text={hydrationWarning || error.message}
text={
isAppDir ? hydrationWarning || error.message : error.message
}
matcher={isNextjsLink}
/>
</p>
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 @@ -23,15 +23,42 @@ const htmlTagsWarnings = new Set([
"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.",
])

export const getHydrationWarningType = (msg: NullableText): 'tag' | 'text' => {
// In React 18, the warning message is prefixed with "Warning: "
const normalizeWarningMessage = (msg: string) => msg.replace(/^Warning: /, '')

// Note: React 18 only
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',
])

// Note: React 18 only
const textMismatchWarning =
'Warning: Text content did not match. Server: "%s" Client: "%s"%s'

const isTextMismatchWarning = (msg: NullableText) => textMismatchWarning === msg
const isTextInTagsMismatchWarning = (msg: NullableText) =>
Boolean(msg && textAndTagsMismatchWarnings.has(msg))

export const getHydrationWarningType = (
msg: NullableText
): 'tag' | 'text' | 'text-in-tag' => {
if (isHtmlTagsWarning(msg)) return 'tag'
return 'text'
}

const isHtmlTagsWarning = (msg: NullableText) =>
Boolean(msg && htmlTagsWarnings.has(msg))
const isHtmlTagsWarning = (msg: NullableText) => {
if (msg && typeof msg === 'string') {
return htmlTagsWarnings.has(normalizeWarningMessage(msg))
}

return false
}

const isKnownHydrationWarning = (msg: NullableText) => isHtmlTagsWarning(msg)
const isKnownHydrationWarning = (msg: NullableText) =>
isHtmlTagsWarning(msg) ||
isTextInTagsMismatchWarning(msg) ||
isTextMismatchWarning(msg)

export const getReactHydrationDiffSegments = (msg: NullableText) => {
if (msg) {
Expand All @@ -51,14 +78,18 @@ export const getReactHydrationDiffSegments = (msg: NullableText) => {
export function storeHydrationErrorStateFromConsoleArgs(...args: any[]) {
const [msg, serverContent, clientContent, componentStack] = args
if (isKnownHydrationWarning(msg)) {
hydrationErrorState.warning = [
// remove the last %s from the message
msg,
serverContent,
clientContent,
]
hydrationErrorState.warning = [msg, serverContent, clientContent]
hydrationErrorState.componentStack = componentStack
hydrationErrorState.serverContent = serverContent
hydrationErrorState.clientContent = clientContent

return [
...args,
// We tack on the hydration error message to the console.error message so that
// it matches the error we display in the redbox overlay
`\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error`,
]
}

return args
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,13 @@ function handleError(error: unknown) {

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

function onUnhandledError(event: ErrorEvent) {
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
2 changes: 1 addition & 1 deletion packages/next/src/compiled/unistore/unistore.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions packages/next/src/server/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -1322,7 +1323,7 @@ export async function renderToHTMLImpl(
) => {
const content = renderContent(EnhancedApp, EnhancedComponent)
return await renderToInitialFizzStream({
ReactDOMServer: ReactDOMServerEdge,
ReactDOMServer: ReactDOMServerBrowser,
element: content,
})
}
Expand Down
4 changes: 4 additions & 0 deletions packages/next/types/react-dom.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
/**
Expand Down
1 change: 1 addition & 0 deletions packages/next/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading

0 comments on commit 0f2845d

Please sign in to comment.