Skip to content

Commit

Permalink
Extend support of Pages router to React 18 (vercel#70219)
Browse files Browse the repository at this point in the history
  • Loading branch information
eps1lon authored and abhi12299 committed Sep 29, 2024
1 parent 0cbe4fb commit b712fdf
Show file tree
Hide file tree
Showing 26 changed files with 1,110 additions and 436 deletions.
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}`

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')
} 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

0 comments on commit b712fdf

Please sign in to comment.