diff --git a/docs/02-app/01-building-your-application/02-data-fetching/02-server-actions-and-mutations.mdx b/docs/02-app/01-building-your-application/02-data-fetching/02-server-actions-and-mutations.mdx index 9284955029dde..336e9f1135671 100644 --- a/docs/02-app/01-building-your-application/02-data-fetching/02-server-actions-and-mutations.mdx +++ b/docs/02-app/01-building-your-application/02-data-fetching/02-server-actions-and-mutations.mdx @@ -358,10 +358,10 @@ export default async function createsUser(formData) { } ``` -Once the fields have been validated on the server, you can return a serializable object in your action and use the React [`useFormState`](https://react.dev/reference/react-dom/hooks/useFormState) hook to show a message to the user. +Once the fields have been validated on the server, you can return a serializable object in your action and use the React [`useActionState`](https://react.dev/reference/react-dom/hooks/useActionState) hook to show a message to the user. -- By passing the action to `useFormState`, the action's function signature changes to receive a new `prevState` or `initialState` parameter as its first argument. -- `useFormState` is a React hook and therefore must be used in a Client Component. +- By passing the action to `useActionState`, the action's function signature changes to receive a new `prevState` or `initialState` parameter as its first argument. +- `useActionState` is a React hook and therefore must be used in a Client Component. ```tsx filename="app/actions.ts" switcher 'use server' @@ -385,12 +385,12 @@ export async function createUser(prevState, formData) { } ``` -Then, you can pass your action to the `useFormState` hook and use the returned `state` to display an error message. +Then, you can pass your action to the `useActionState` hook and use the returned `state` to display an error message. ```tsx filename="app/ui/signup.tsx" switcher 'use client' -import { useFormState } from 'react-dom' +import { useActionState } from 'react' import { createUser } from '@/app/actions' const initialState = { @@ -398,7 +398,7 @@ const initialState = { } export function Signup() { - const [state, formAction] = useFormState(createUser, initialState) + const [state, formAction] = useActionState(createUser, initialState) return (
@@ -417,7 +417,7 @@ export function Signup() { ```jsx filename="app/ui/signup.js" switcher 'use client' -import { useFormState } from 'react-dom' +import { useActionState } from 'react' import { createUser } from '@/app/actions' const initialState = { @@ -425,7 +425,7 @@ const initialState = { } export function Signup() { - const [state, formAction] = useFormState(createUser, initialState) + const [state, formAction] = useActionState(createUser, initialState) return ( @@ -739,7 +739,7 @@ export async function createTodo(prevState, formData) { > **Good to know:** > -> - Aside from throwing the error, you can also return an object to be handled by `useFormState`. See [Server-side validation and error handling](#server-side-validation-and-error-handling). +> - Aside from throwing the error, you can also return an object to be handled by `useActionState`. See [Server-side validation and error handling](#server-side-validation-and-error-handling). ### Revalidating data @@ -1002,5 +1002,5 @@ For more information on Server Actions, check out the following React docs: - [`"use server"`](https://react.dev/reference/react/use-server) - [``](https://react.dev/reference/react-dom/components/form) - [`useFormStatus`](https://react.dev/reference/react-dom/hooks/useFormStatus) -- [`useFormState`](https://react.dev/reference/react-dom/hooks/useFormState) +- [`useActionState`](https://react.dev/reference/react-dom/hooks/useActionState) - [`useOptimistic`](https://react.dev/reference/react/useOptimistic) diff --git a/docs/02-app/01-building-your-application/09-authentication/index.mdx b/docs/02-app/01-building-your-application/09-authentication/index.mdx index 36934af91ae42..14289f4bcf91a 100644 --- a/docs/02-app/01-building-your-application/09-authentication/index.mdx +++ b/docs/02-app/01-building-your-application/09-authentication/index.mdx @@ -29,7 +29,7 @@ The examples on this page walk through basic username and password auth for educ ### Sign-up and login functionality -You can use the [``](https://react.dev/reference/react-dom/components/form) element with React's [Server Actions](/docs/app/building-your-application/rendering/server-components), [`useFormStatus()`](https://react.dev/reference/react-dom/hooks/useFormStatus), and [`useFormState()`](https://react.dev/reference/react-dom/hooks/useFormState) to capture user credentials, validate form fields, and call your Authentication Provider's API or database. +You can use the [``](https://react.dev/reference/react-dom/components/form) element with React's [Server Actions](/docs/app/building-your-application/rendering/server-components), [`useFormStatus()`](https://react.dev/reference/react-dom/hooks/useFormStatus), and [`useActionState()`](https://react.dev/reference/react/useActionState) to capture user credentials, validate form fields, and call your Authentication Provider's API or database. Since Server Actions always execute on the server, they provide a secure environment for handling authentication logic. @@ -200,16 +200,16 @@ export async function signup(state, formData) { } ``` -Back in your ``, you can use React's `useFormState()` hook to display validation errors to the user: +Back in your ``, you can use React's `useActionState()` hook to display validation errors to the user: ```tsx filename="app/ui/signup-form.tsx" switcher highlight={7,15,21,27-36} 'use client' -import { useFormState } from 'react-dom' +import { useActionState } from 'react' import { signup } from '@/app/actions/auth' export function SignupForm() { - const [state, action] = useFormState(signup, undefined) + const [state, action] = useActionState(signup, undefined) return ( @@ -248,11 +248,11 @@ export function SignupForm() { ```jsx filename="app/ui/signup-form.js" switcher highlight={7,15,21,27-36} 'use client' -import { useFormState } from 'react-dom' +import { useActionState } from 'react' import { signup } from '@/app/actions/auth' export function SignupForm() { - const [state, action] = useFormState(signup, undefined) + const [state, action] = useActionState(signup, undefined) return ( @@ -293,7 +293,8 @@ You can also use the `useFormStatus()` hook to handle the pending state on form ```tsx filename="app/ui/signup-form.tsx" highlight={6} switcher 'use client' -import { useFormStatus, useFormState } from 'react-dom' +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' export function SignupButton() { const { pending } = useFormStatus() @@ -309,7 +310,8 @@ export function SignupButton() { ```jsx filename="app/ui/signup-form.js" highlight={6} switcher 'use client' -import { useFormStatus, useFormState } from 'react-dom' +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' export function SignupButton() { const { pending } = useFormStatus() diff --git a/examples/next-forms/app/add-form.tsx b/examples/next-forms/app/add-form.tsx index 1cadaee2b4277..4f5bb968e8be7 100644 --- a/examples/next-forms/app/add-form.tsx +++ b/examples/next-forms/app/add-form.tsx @@ -1,6 +1,7 @@ "use client"; -import { useFormState, useFormStatus } from "react-dom"; +import { useActionState } from "react"; +import { useFormStatus } from "react-dom"; import { createTodo } from "@/app/actions"; const initialState = { @@ -18,7 +19,7 @@ function SubmitButton() { } export function AddForm() { - const [state, formAction] = useFormState(createTodo, initialState); + const [state, formAction] = useActionState(createTodo, initialState); return ( diff --git a/examples/next-forms/app/delete-form.tsx b/examples/next-forms/app/delete-form.tsx index acc633e972f61..b15eb1940b1df 100644 --- a/examples/next-forms/app/delete-form.tsx +++ b/examples/next-forms/app/delete-form.tsx @@ -1,6 +1,7 @@ "use client"; -import { useFormState, useFormStatus } from "react-dom"; +import { useActionState } from "react"; +import { useFormStatus } from "react-dom"; import { deleteTodo } from "@/app/actions"; const initialState = { @@ -18,7 +19,7 @@ function DeleteButton() { } export function DeleteForm({ id, todo }: { id: number; todo: string }) { - const [state, formAction] = useFormState(deleteTodo, initialState); + const [state, formAction] = useActionState(deleteTodo, initialState); return ( diff --git a/examples/with-fauna/components/EntryForm.tsx b/examples/with-fauna/components/EntryForm.tsx index eca9d68d668db..224f14d862599 100644 --- a/examples/with-fauna/components/EntryForm.tsx +++ b/examples/with-fauna/components/EntryForm.tsx @@ -3,7 +3,9 @@ import cn from "classnames"; import { createEntryAction } from "@/actions/entry"; // @ts-ignore -import { useFormState, useFormStatus } from "react-dom"; +import { useActionState } from "react"; +// @ts-ignore +import { useFormStatus } from "react-dom"; import LoadingSpinner from "@/components/LoadingSpinner"; import SuccessMessage from "@/components/SuccessMessage"; import ErrorMessage from "@/components/ErrorMessage"; @@ -20,7 +22,7 @@ const initialState = { }; export default function EntryForm() { - const [state, formAction] = useFormState(createEntryAction, initialState); + const [state, formAction] = useActionState(createEntryAction, initialState); const { pending } = useFormStatus(); return ( diff --git a/package.json b/package.json index 9047f3e1a877b..918d48d7df562 100644 --- a/package.json +++ b/package.json @@ -196,18 +196,18 @@ "pretty-bytes": "5.3.0", "pretty-ms": "7.0.0", "random-seed": "0.3.0", - "react": "18.2.0", + "react": "19.0.0-beta-4508873393-20240430", "react-17": "npm:react@17.0.2", - "react-builtin": "npm:react@18.3.0-canary-c3048aab4-20240326", - "react-dom": "18.2.0", + "react-builtin": "npm:react@19.0.0-beta-4508873393-20240430", + "react-dom": "19.0.0-beta-4508873393-20240430", "react-dom-17": "npm:react-dom@17.0.2", - "react-dom-builtin": "npm:react-dom@18.3.0-canary-c3048aab4-20240326", - "react-dom-experimental-builtin": "npm:react-dom@0.0.0-experimental-c3048aab4-20240326", - "react-experimental-builtin": "npm:react@0.0.0-experimental-c3048aab4-20240326", - "react-server-dom-turbopack": "18.3.0-canary-c3048aab4-20240326", - "react-server-dom-turbopack-experimental": "npm:react-server-dom-turbopack@0.0.0-experimental-c3048aab4-20240326", - "react-server-dom-webpack": "18.3.0-canary-c3048aab4-20240326", - "react-server-dom-webpack-experimental": "npm:react-server-dom-webpack@0.0.0-experimental-c3048aab4-20240326", + "react-dom-builtin": "npm:react-dom@19.0.0-beta-4508873393-20240430", + "react-dom-experimental-builtin": "npm:react-dom@0.0.0-experimental-4508873393-20240430", + "react-experimental-builtin": "npm:react@0.0.0-experimental-4508873393-20240430", + "react-server-dom-turbopack": "19.0.0-beta-4508873393-20240430", + "react-server-dom-turbopack-experimental": "npm:react-server-dom-turbopack@0.0.0-experimental-4508873393-20240430", + "react-server-dom-webpack": "19.0.0-beta-4508873393-20240430", + "react-server-dom-webpack-experimental": "npm:react-server-dom-webpack@0.0.0-experimental-4508873393-20240430", "react-ssr-prepass": "1.0.8", "react-virtualized": "9.22.3", "relay-compiler": "13.0.2", @@ -217,8 +217,8 @@ "resolve-from": "5.0.0", "sass": "1.54.0", "satori": "0.10.9", - "scheduler-builtin": "npm:scheduler@0.24.0-canary-c3048aab4-20240326", - "scheduler-experimental-builtin": "npm:scheduler@0.0.0-experimental-c3048aab4-20240326", + "scheduler-builtin": "npm:scheduler@0.25.0-beta-4508873393-20240430", + "scheduler-experimental-builtin": "npm:scheduler@0.0.0-experimental-4508873393-20240430", "seedrandom": "3.0.5", "selenium-webdriver": "4.0.0-beta.4", "semver": "7.3.7", @@ -252,7 +252,11 @@ "@babel/types": "7.22.5", "@babel/traverse": "7.22.5", "@types/react": "18.2.74", - "@types/react-dom": "18.2.23" + "@types/react-dom": "18.2.23", + "react": "19.0.0-beta-4508873393-20240430", + "react-dom": "19.0.0-beta-4508873393-20240430", + "react-is": "19.0.0-beta-4508873393-20240430", + "scheduler": "0.25.0-beta-94eed63c49-20240425" }, "engines": { "node": ">=18.17.0", diff --git a/packages/next-swc/crates/next-core/src/next_import_map.rs b/packages/next-swc/crates/next-core/src/next_import_map.rs index e80def16aed70..26c1e67c70117 100644 --- a/packages/next-swc/crates/next-core/src/next_import_map.rs +++ b/packages/next-swc/crates/next-core/src/next_import_map.rs @@ -728,16 +728,21 @@ async fn rsc_aliases( } } - if runtime == NextRuntime::Edge { - if ty.supports_react_server() { - alias["react"] = format!("next/dist/compiled/react{react_channel}/react.react-server"); - alias["react-dom"] = - format!("next/dist/compiled/react-dom{react_channel}/react-dom.react-server"); - } else { - // x-ref: https://github.com/facebook/react/pull/25436 - alias["react-dom"] = - format!("next/dist/compiled/react-dom{react_channel}/server-rendering-stub"); - } + if runtime == NextRuntime::Edge && ty.supports_react_server() { + alias.extend(indexmap! { + "react" => format!("next/dist/compiled/react{react_channel}/react.react-server"), + "next/dist/compiled/react" => format!("next/dist/compiled/react{react_channel}/react.react-server"), + "next/dist/compiled/react-experimental" => format!("next/dist/compiled/react-experimental/react.react-server"), + "react/jsx-runtime" => format!("next/dist/compiled/react{react_channel}/jsx-runtime.react-server"), + "next/dist/compiled/react/jsx-runtime" => format!("next/dist/compiled/react{react_channel}/jsx-runtime.react-server"), + "next/dist/compiled/react-experimental/jsx-runtime" => format!("next/dist/compiled/react-experimental/jsx-runtime.react-server"), + "react/jsx-dev-runtime" => format!("next/dist/compiled/react{react_channel}/jsx-dev-runtime.react-server"), + "next/dist/compiled/react/jsx-dev-runtime" => format!("next/dist/compiled/react{react_channel}/jsx-dev-runtime.react-server"), + "next/dist/compiled/react-experimental/jsx-dev-runtime" => format!("next/dist/compiled/react-experimental/jsx-dev-runtime.react-server"), + "react-dom" => format!("next/dist/compiled/react-dom{react_channel}/react-dom.react-server"), + "next/dist/compiled/react-dom" => format!("next/dist/compiled/react-dom{react_channel}/react-dom.react-server"), + "next/dist/compiled/react-dom-experimental" => format!("next/dist/compiled/react-dom-experimental/react-dom.react-server"), + }) } insert_exact_alias_map(import_map, project_path, alias); diff --git a/packages/next-swc/crates/next-custom-transforms/src/transforms/react_server_components.rs b/packages/next-swc/crates/next-custom-transforms/src/transforms/react_server_components.rs index 11daf1179bca2..40eabd224da83 100644 --- a/packages/next-swc/crates/next-custom-transforms/src/transforms/react_server_components.rs +++ b/packages/next-swc/crates/next-custom-transforms/src/transforms/react_server_components.rs @@ -506,12 +506,12 @@ impl ReactServerComponentValidator { "useSyncExternalStore", "useTransition", "useOptimistic", + "useActionState", ], ), ( "react-dom", vec![ - "findDOMNode", "flushSync", "unstable_batchedUpdates", "useFormStatus", diff --git a/packages/next-swc/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/react-dom-api/input.js b/packages/next-swc/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/react-dom-api/input.js index 3b7a161c4b373..a003a40719177 100644 --- a/packages/next-swc/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/react-dom-api/input.js +++ b/packages/next-swc/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/react-dom-api/input.js @@ -1,4 +1,6 @@ -import { findDOMNode, flushSync, unstable_batchedUpdates } from 'react-dom' +import { flushSync, unstable_batchedUpdates } from 'react-dom' + +import { useActionState } from 'react' import { useFormStatus, useFormState } from 'react-dom' diff --git a/packages/next-swc/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/react-dom-api/output.js b/packages/next-swc/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/react-dom-api/output.js index 742749a85ed92..2637549fadc85 100644 --- a/packages/next-swc/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/react-dom-api/output.js +++ b/packages/next-swc/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/react-dom-api/output.js @@ -1,4 +1,5 @@ -import { findDOMNode, flushSync, unstable_batchedUpdates } from 'react-dom'; +import { flushSync, unstable_batchedUpdates } from 'react-dom'; +import { useActionState } from 'react' import { useFormStatus, useFormState } from 'react-dom'; export default function() { return null; diff --git a/packages/next-swc/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/react-dom-api/output.stderr b/packages/next-swc/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/react-dom-api/output.stderr index 64c0485b05f86..4cd910c9da2ee 100644 --- a/packages/next-swc/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/react-dom-api/output.stderr +++ b/packages/next-swc/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/react-dom-api/output.stderr @@ -1,39 +1,40 @@ - x You're importing a component that needs findDOMNode. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default. + x You're importing a component that needs flushSync. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default. | Learn more: https://nextjs.org/docs/getting-started/react-essentials | | ,-[input.js:1:1] - 1 | import { findDOMNode, flushSync, unstable_batchedUpdates } from 'react-dom' - : ^^^^^^^^^^^ + 1 | import { flushSync, unstable_batchedUpdates } from 'react-dom' + : ^^^^^^^^^ `---- - x You're importing a component that needs flushSync. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default. + x You're importing a component that needs unstable_batchedUpdates. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by + | default. | Learn more: https://nextjs.org/docs/getting-started/react-essentials | | ,-[input.js:1:1] - 1 | import { findDOMNode, flushSync, unstable_batchedUpdates } from 'react-dom' - : ^^^^^^^^^ + 1 | import { flushSync, unstable_batchedUpdates } from 'react-dom' + : ^^^^^^^^^^^^^^^^^^^^^^^ `---- - x You're importing a component that needs unstable_batchedUpdates. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by - | default. + x You're importing a component that needs useActionState. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default. | Learn more: https://nextjs.org/docs/getting-started/react-essentials | | - ,-[input.js:1:1] - 1 | import { findDOMNode, flushSync, unstable_batchedUpdates } from 'react-dom' - : ^^^^^^^^^^^^^^^^^^^^^^^ + ,-[input.js:2:1] + 2 | + 3 | import { useActionState } from 'react' + : ^^^^^^^^^^^^^^ `---- x You're importing a component that needs useFormStatus. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default. | Learn more: https://nextjs.org/docs/getting-started/react-essentials | | - ,-[input.js:2:1] - 2 | - 3 | import { useFormStatus, useFormState } from 'react-dom' + ,-[input.js:4:1] + 4 | + 5 | import { useFormStatus, useFormState } from 'react-dom' : ^^^^^^^^^^^^^ `---- @@ -41,8 +42,8 @@ | Learn more: https://nextjs.org/docs/getting-started/react-essentials | | - ,-[input.js:2:1] - 2 | - 3 | import { useFormStatus, useFormState } from 'react-dom' + ,-[input.js:4:1] + 4 | + 5 | import { useFormStatus, useFormState } from 'react-dom' : ^^^^^^^^^^^^ `---- diff --git a/packages/next/package.json b/packages/next/package.json index 82ebd8605dc6f..a97aad346ba50 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -104,8 +104,8 @@ "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "19.0.0-beta-4508873393-20240430", + "react-dom": "19.0.0-beta-4508873393-20240430", "sass": "^1.3.0" }, "peerDependenciesMeta": { @@ -282,7 +282,7 @@ "punycode": "2.1.1", "querystring-es3": "0.2.1", "raw-body": "2.4.1", - "react-is": "18.2.0", + "react-is": "19.0.0-canary-94eed63c49-20240425", "react-refresh": "0.12.0", "regenerator-runtime": "0.13.4", "sass-loader": "12.4.0", diff --git a/packages/next/src/build/create-compiler-aliases.ts b/packages/next/src/build/create-compiler-aliases.ts index 58730bb56479e..e3eacf57740cd 100644 --- a/packages/next/src/build/create-compiler-aliases.ts +++ b/packages/next/src/build/create-compiler-aliases.ts @@ -290,23 +290,26 @@ export function createRSCAliases( if (isEdgeServer) { if (layer === WEBPACK_LAYERS.reactServerComponents) { - alias[ - 'react$' - ] = `next/dist/compiled/react${bundledReactChannel}/react.react-server` - alias[ - 'react-dom$' - ] = `next/dist/compiled/react-dom${bundledReactChannel}/react-dom.react-server` - } else { - // x-ref: https://github.com/facebook/react/pull/25436 - alias[ - 'react-dom$' - ] = `next/dist/compiled/react-dom${bundledReactChannel}/server-rendering-stub` + alias = Object.assign(alias, { + react$: `next/dist/compiled/react${bundledReactChannel}/react.react-server`, + 'next/dist/compiled/react$': `next/dist/compiled/react${bundledReactChannel}/react.react-server`, + 'next/dist/compiled/react-experimental$': `next/dist/compiled/react-experimental/react.react-server`, + 'react/jsx-runtime$': `next/dist/compiled/react${bundledReactChannel}/jsx-runtime.react-server`, + 'next/dist/compiled/react/jsx-runtime$': `next/dist/compiled/react${bundledReactChannel}/jsx-runtime.react-server`, + 'next/dist/compiled/react-experimental/jsx-runtime$': `next/dist/compiled/react-experimental/jsx-runtime.react-server`, + 'react/jsx-dev-runtime$': `next/dist/compiled/react${bundledReactChannel}/jsx-dev-runtime.react-server`, + 'next/dist/compiled/react/jsx-dev-runtime$': `next/dist/compiled/react${bundledReactChannel}/jsx-dev-runtime.react-server`, + 'next/dist/compiled/react-experimental/jsx-dev-runtime$': `next/dist/compiled/react-experimental/jsx-dev-runtime.react-server`, + 'react-dom$': `next/dist/compiled/react-dom${bundledReactChannel}/react-dom.react-server`, + 'next/dist/compiled/react-dom$': `next/dist/compiled/react-dom${bundledReactChannel}/react-dom.react-server`, + 'next/dist/compiled/react-dom-experimental$': `next/dist/compiled/react-dom-experimental/react-dom.react-server`, + }) } } if (reactProductionProfiling) { alias[ - 'react-dom$' + 'react-dom/client$' ] = `next/dist/compiled/react-dom${bundledReactChannel}/profiling` } @@ -373,6 +376,6 @@ function getBarrelOptimizationAliases(packages: string[]): CompilerAliases { } function getReactProfilingInProduction(): CompilerAliases { return { - 'react-dom$': 'react-dom/profiling', + 'react-dom/client$': 'react-dom/profiling', } } diff --git a/packages/next/src/build/webpack/alias/react-dom-server-browser-experimental.js b/packages/next/src/build/webpack/alias/react-dom-server-browser-experimental.js index a5d6643a6c240..ce1271810aa34 100644 --- a/packages/next/src/build/webpack/alias/react-dom-server-browser-experimental.js +++ b/packages/next/src/build/webpack/alias/react-dom-server-browser-experimental.js @@ -1,7 +1,7 @@ var l, s if (process.env.NODE_ENV === 'production') { - l = require('next/dist/compiled/react-dom-experimental/cjs/react-dom-server-legacy.browser.production.min.js') - s = require('next/dist/compiled/react-dom-experimental/cjs/react-dom-server.browser.production.min.js') + l = require('next/dist/compiled/react-dom-experimental/cjs/react-dom-server-legacy.browser.production.js') + s = require('next/dist/compiled/react-dom-experimental/cjs/react-dom-server.browser.production.js') } else { l = require('next/dist/compiled/react-dom-experimental/cjs/react-dom-server-legacy.browser.development.js') s = require('next/dist/compiled/react-dom-experimental/cjs/react-dom-server.browser.development.js') diff --git a/packages/next/src/build/webpack/alias/react-dom-server-browser.js b/packages/next/src/build/webpack/alias/react-dom-server-browser.js index f462b442ae41e..196b4b40f2a82 100644 --- a/packages/next/src/build/webpack/alias/react-dom-server-browser.js +++ b/packages/next/src/build/webpack/alias/react-dom-server-browser.js @@ -1,7 +1,7 @@ var l, s if (process.env.NODE_ENV === 'production') { - l = require('next/dist/compiled/react-dom/cjs/react-dom-server-legacy.browser.production.min.js') - s = require('next/dist/compiled/react-dom/cjs/react-dom-server.browser.production.min.js') + l = require('next/dist/compiled/react-dom/cjs/react-dom-server-legacy.browser.production.js') + s = require('next/dist/compiled/react-dom/cjs/react-dom-server.browser.production.js') } else { l = require('next/dist/compiled/react-dom/cjs/react-dom-server-legacy.browser.development.js') s = require('next/dist/compiled/react-dom/cjs/react-dom-server.browser.development.js') diff --git a/packages/next/src/build/webpack/alias/react-dom-server-edge-experimental.js b/packages/next/src/build/webpack/alias/react-dom-server-edge-experimental.js index b14af3c288f1c..c73fadf1b4431 100644 --- a/packages/next/src/build/webpack/alias/react-dom-server-edge-experimental.js +++ b/packages/next/src/build/webpack/alias/react-dom-server-edge-experimental.js @@ -7,7 +7,7 @@ function error() { var b if (process.env.NODE_ENV === 'production') { - b = require('next/dist/compiled/react-dom-experimental/cjs/react-dom-server.edge.production.min.js') + b = require('next/dist/compiled/react-dom-experimental/cjs/react-dom-server.edge.production.js') } else { b = require('next/dist/compiled/react-dom-experimental/cjs/react-dom-server.edge.development.js') } diff --git a/packages/next/src/build/webpack/alias/react-dom-server-edge.js b/packages/next/src/build/webpack/alias/react-dom-server-edge.js index f6b6056bc271e..9d2c93e96193e 100644 --- a/packages/next/src/build/webpack/alias/react-dom-server-edge.js +++ b/packages/next/src/build/webpack/alias/react-dom-server-edge.js @@ -7,7 +7,7 @@ function error() { var b if (process.env.NODE_ENV === 'production') { - b = require('next/dist/compiled/react-dom/cjs/react-dom-server.edge.production.min.js') + b = require('next/dist/compiled/react-dom/cjs/react-dom-server.edge.production.js') } else { b = require('next/dist/compiled/react-dom/cjs/react-dom-server.edge.development.js') } diff --git a/packages/next/src/client/app-index.tsx b/packages/next/src/client/app-index.tsx index 75867b17e5074..f59ee8507b412 100644 --- a/packages/next/src/client/app-index.tsx +++ b/packages/next/src/client/app-index.tsx @@ -7,7 +7,7 @@ import React, { use } from 'react' import { createFromReadableStream } from 'react-server-dom-webpack/client' import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime' -import onRecoverableError from './on-recoverable-error' +import { onRecoverableError } from './on-recoverable-error' import { callServer } from './app-call-server' import { isNextRouterError } from './components/is-next-router-error' import { @@ -165,7 +165,9 @@ export function hydrate() { const rootLayoutMissingTags = window.__next_root_layout_missing_tags const hasMissingTags = !!rootLayoutMissingTags?.length - const options = { onRecoverableError } satisfies ReactDOMClient.RootOptions + const options = { + onRecoverableError, + } satisfies ReactDOMClient.RootOptions const isError = document.documentElement.id === '__next_error__' || hasMissingTags diff --git a/packages/next/src/client/components/is-hydration-error.ts b/packages/next/src/client/components/is-hydration-error.ts index eaa9b0df90548..f66eda30984c8 100644 --- a/packages/next/src/client/components/is-hydration-error.ts +++ b/packages/next/src/client/components/is-hydration-error.ts @@ -3,6 +3,62 @@ import isError from '../../lib/is-error' const hydrationErrorRegex = /hydration failed|while hydrating|content does not match|did not match/i +const reactUnifiedMismatchWarning = `Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used` + +const reactHydrationErrorDocLink = 'https://react.dev/link/hydration-mismatch' + +export const getDefaultHydrationErrorMessage = () => { + return ( + reactUnifiedMismatchWarning + + '\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error' + ) +} + export function isHydrationError(error: unknown): boolean { return isError(error) && hydrationErrorRegex.test(error.message) } + +export function isReactHydrationErrorStack(stack: string): boolean { + return stack.startsWith(reactUnifiedMismatchWarning) +} + +export function getHydrationErrorStackInfo(rawMessage: string): { + message: string | null + link?: string + stack?: string + diff?: string +} { + rawMessage = rawMessage.replace(/^Error: /, '') + if (!isReactHydrationErrorStack(rawMessage)) { + return { message: null } + } + rawMessage = rawMessage.slice(reactUnifiedMismatchWarning.length + 1).trim() + const [message, trailing] = rawMessage.split(`${reactHydrationErrorDocLink}`) + const trimmedMessage = message.trim() + // React built-in hydration diff starts with a newline, checking if length is > 1 + if (trailing && trailing.length > 1) { + const stacks: string[] = [] + const diffs: string[] = [] + trailing.split('\n').forEach((line) => { + if (line.trim() === '') return + if (line.trim().startsWith('at ')) { + stacks.push(line) + } else { + diffs.push(line) + } + }) + + return { + message: trimmedMessage, + link: reactHydrationErrorDocLink, + diff: diffs.join('\n'), + stack: stacks.join('\n'), + } + } else { + return { + message: trimmedMessage, + link: reactHydrationErrorDocLink, + stack: trailing, // without hydration diff + } + } +} diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index 1c3698c604967..f70b813e9f89b 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -86,13 +86,21 @@ function walkAddRefetch( return treeToRecreate } +const __DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE = ( + ReactDOM as any +).__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE + // TODO-APP: Replace with new React API for finding dom nodes without a `ref` when available /** * Wraps ReactDOM.findDOMNode with additional logic to hide React Strict Mode warning */ function findDOMNode( - instance: Parameters[0] -): ReturnType { + instance: React.ReactInstance | null | undefined +): Element | Text | null { + // __DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.findDOMNode is null during module init. + // We need to lazily reference it. + const internal_reactDOMfindDOMNode = + __DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.findDOMNode // Tree-shake for server bundle if (typeof window === 'undefined') return null // Only apply strict mode warning when not in production @@ -105,12 +113,12 @@ function findDOMNode( originalConsoleError(...messages) } } - return ReactDOM.findDOMNode(instance) + return internal_reactDOMfindDOMNode(instance) } finally { console.error = originalConsoleError! } } - return ReactDOM.findDOMNode(instance) + return internal_reactDOMfindDOMNode(instance) } const rectProperties = [ diff --git a/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx b/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx index 895f4c2054b13..29090e8cbb1f2 100644 --- a/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx +++ b/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx @@ -483,15 +483,17 @@ export default function HotReload({ | HydrationErrorState | undefined // Component stack is added to the error in use-error-handler in case there was a hydration errror - const componentStack = errorDetails?.componentStack + const componentStackTrace = + (error as any)._componentStack || errorDetails?.componentStack const warning = errorDetails?.warning dispatch({ type: ACTION_UNHANDLED_ERROR, reason: error, frames: parseStack(error.stack!), - componentStackFrames: componentStack - ? parseComponentStack(componentStack) - : undefined, + componentStackFrames: + typeof componentStackTrace === 'string' + ? parseComponentStack(componentStackTrace) + : undefined, warning, }) }, diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx index 674828bead911..9a9a9858300be 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx @@ -227,6 +227,7 @@ export function Errors({ ) const errorDetails: HydrationErrorState = (error as any).details || {} + const notes = errorDetails.notes || '' const [warningTemplate, serverContent, clientContent] = errorDetails.warning || [null, '', ''] @@ -238,6 +239,7 @@ export function Errors({ .replace('%s', '') // remove the %s for stack .replace(/%s$/, '') // If there's still a %s at the end, remove it .replace(/^Warning: /, '') + .replace(/^Error: /, '') : null return ( @@ -272,28 +274,36 @@ export function Errors({ id="nextjs__container_errors_desc" className="nextjs__container_errors_desc" > - {error.name}:{' '} - + {/* If there's hydration warning, skip displaying the error name */} + {hydrationWarning ? '' : error.name + ': '} +

- {hydrationWarning && ( + {notes ? ( <>

- {hydrationWarning} + {notes}

- {activeError.componentStackFrames?.length ? ( - - ) : null} - )} + ) : null} + + {hydrationWarning && + (activeError.componentStackFrames?.length || + !!errorDetails.reactOutputComponentDiff) ? ( + + ) : null} {isServerError ? (
diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx index 58f3156b22256..58b2e2d199665 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/component-stack-pseudo-html.tsx @@ -59,25 +59,84 @@ export function PseudoHtmlDiff({ firstContent, secondContent, hydrationMismatchType, + reactOutputComponentDiff, ...props }: { componentStackFrames: ComponentStackFrame[] firstContent: string secondContent: string + reactOutputComponentDiff: string | undefined hydrationMismatchType: 'tag' | 'text' | 'text-in-tag' } & React.HTMLAttributes) { const isHtmlTagsWarning = hydrationMismatchType === 'tag' + const isReactHydrationDiff = !!reactOutputComponentDiff + // For text mismatch, mismatched text will take 2 rows, so we display 4 rows of component stack const MAX_NON_COLLAPSED_FRAMES = isHtmlTagsWarning ? 6 : 4 - const shouldCollapse = componentStackFrames.length > MAX_NON_COLLAPSED_FRAMES - const [isHtmlCollapsed, toggleCollapseHtml] = useState(shouldCollapse) + const [isHtmlCollapsed, toggleCollapseHtml] = useState(true) const htmlComponents = useMemo(() => { + const componentStacks: React.ReactNode[] = [] + // React 19 unified mismatch + if (isReactHydrationDiff) { + let currentComponentIndex = componentStackFrames.length - 1 + const reactComponentDiffLines = reactOutputComponentDiff.split('\n') + const diffHtmlStack: React.ReactNode[] = [] + reactComponentDiffLines.forEach((line, index) => { + let trimmedLine = line.trim() + const isDiffLine = trimmedLine[0] === '+' || trimmedLine[0] === '-' + const spaces = ' '.repeat(componentStacks.length * 2) + + if (isDiffLine) { + const sign = trimmedLine[0] + trimmedLine = trimmedLine.slice(1).trim() // trim spaces after sign + diffHtmlStack.push( + + {sign} + {spaces} + {trimmedLine} + {'\n'} + + ) + } else if (currentComponentIndex >= 0) { + const isUserLandComponent = trimmedLine.startsWith( + '<' + componentStackFrames[currentComponentIndex].component + ) + // If it's matched userland component or it's ... we will keep the component stack in diff + if (isUserLandComponent || trimmedLine === '...') { + currentComponentIndex-- + componentStacks.push( + + {spaces} + {trimmedLine} + {'\n'} + + ) + } else if (!isHtmlCollapsed) { + componentStacks.push( + + {spaces} + {trimmedLine} + {'\n'} + + ) + } + } + }) + return componentStacks.concat(diffHtmlStack) + } + + const nestedHtmlStack: React.ReactNode[] = [] const tagNames = isHtmlTagsWarning ? // tags could have < or > in the name, so we always remove them to match [firstContent.replace(/<|>/g, ''), secondContent.replace(/<|>/g, '')] : [] - const nestedHtmlStack: React.ReactNode[] = [] + let lastText = '' const componentStack = componentStackFrames @@ -105,10 +164,8 @@ export function PseudoHtmlDiff({ componentStack.forEach((component, index, componentList) => { const spaces = ' '.repeat(nestedHtmlStack.length * 2) - // const prevComponent = componentList[index - 1] - // const nextComponent = componentList[index + 1] - // When component is the server or client tag name, highlight it + // When component is the server or client tag name, highlight it const isHighlightedTag = isHtmlTagsWarning ? index === matchedIndex[0] || index === matchedIndex[1] : tagNames.includes(component) @@ -181,7 +238,6 @@ export function PseudoHtmlDiff({ } } }) - // Hydration mismatch: text or text-tag if (!isHtmlTagsWarning) { const spaces = ' '.repeat(nestedHtmlStack.length * 2) @@ -190,22 +246,22 @@ export function PseudoHtmlDiff({ // hydration type is "text", represent [server content, client content] wrappedCodeLine = ( - + {spaces + `"${firstContent}"\n`} - + {spaces + `"${secondContent}"\n`} ) - } else { + } else if (hydrationMismatchType === 'text-in-tag') { // hydration type is "text-in-tag", represent [parent tag, mismatch content] wrappedCodeLine = ( {spaces + `<${secondContent}>\n`} - + {spaces + ` "${firstContent}"\n`} @@ -223,6 +279,8 @@ export function PseudoHtmlDiff({ isHtmlTagsWarning, hydrationMismatchType, MAX_NON_COLLAPSED_FRAMES, + isReactHydrationDiff, + reactOutputComponentDiff, ]) return ( diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx index 8e9ec07791633..f68ac1582b067 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx @@ -200,10 +200,10 @@ export const styles = css` border: none; padding: 0; } - [data-nextjs-container-errors-pseudo-html--diff-add] { + [data-nextjs-container-errors-pseudo-html--diff='add'] { color: var(--color-ansi-green); } - [data-nextjs-container-errors-pseudo-html--diff-remove] { + [data-nextjs-container-errors-pseudo-html--diff='remove'] { color: var(--color-ansi-red); } [data-nextjs-container-errors-pseudo-html--tag-error] { diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts index 731bba840c363..318514a181466 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/hydration-error-info.ts @@ -1,13 +1,37 @@ +import { getHydrationErrorStackInfo } from '../../../is-hydration-error' + export type HydrationErrorState = { - // [message, serverContent, clientContent] + // Hydration warning template format: warning?: [string, string, string] componentStack?: string serverContent?: string clientContent?: string + // React 19 hydration diff format: + notes?: string + reactOutputComponentDiff?: string } type NullableText = string | null | undefined +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([ + 'Warning: Cannot render a sync or defer '); /** - * This escaping function is designed to work with bootstrapScriptContent and importMap only. - * because we know we are escaping the entire script. We can avoid for instance + * This escaping function is designed to work with with inline scripts where the entire + * contents are escaped. Because we know we are escaping the entire script we can avoid for instance * escaping html comment string sequences that are valid javascript as well because * if there are no sebsequent '); * ensure that the script cannot be early terminated or never terminated state */ -function escapeBootstrapAndImportMapScriptContent(scriptText) { +function escapeEntireInlineScriptContent(scriptText) { { checkHtmlStringCoercion(scriptText); } @@ -2092,7 +2084,7 @@ function createRenderState$1(resumableState, nonce, externalRuntimeConfig, impor bootstrapModules = resumableState.bootstrapModules; if (bootstrapScriptContent !== undefined) { - bootstrapChunks.push(inlineScriptWithNonce, stringToChunk(escapeBootstrapAndImportMapScriptContent(bootstrapScriptContent)), endInlineScript); + bootstrapChunks.push(inlineScriptWithNonce, stringToChunk(escapeEntireInlineScriptContent(bootstrapScriptContent)), endInlineScript); } { @@ -2128,7 +2120,7 @@ function createRenderState$1(resumableState, nonce, externalRuntimeConfig, impor if (importMap !== undefined) { var map = importMap; importMapChunks.push(importMapScriptStart); - importMapChunks.push(stringToChunk(escapeBootstrapAndImportMapScriptContent(JSON.stringify(map)))); + importMapChunks.push(stringToChunk(escapeEntireInlineScriptContent(JSON.stringify(map)))); importMapChunks.push(importMapScriptEnd); } @@ -2817,6 +2809,18 @@ function pushAttribute(target, name, value) // not null or undefined return; } + case 'inert': + { + { + if (value === '' && !didWarnForNewBooleanPropsWithEmptyValue[name]) { + didWarnForNewBooleanPropsWithEmptyValue[name] = true; + + error('Received an empty string for a boolean attribute `%s`. ' + 'This will treat the attribute as if it were false. ' + 'Either pass `false` to silence this warning, or ' + 'pass `true` if you used an empty string in earlier versions of React to indicate this attribute is true.', name); + } + } + } + // Fallthrough for boolean props that don't have a warning for empty strings. + case 'allowFullScreen': case 'async': case 'autoPlay': @@ -2921,27 +2925,6 @@ function pushAttribute(target, name, value) // not null or undefined pushStringAttribute(target, 'xml:space', value); return; - case 'inert': - { - { - { - if (value === '' && !didWarnForNewBooleanPropsWithEmptyValue[name]) { - didWarnForNewBooleanPropsWithEmptyValue[name] = true; - - error('Received an empty string for a boolean attribute `%s`. ' + 'This will treat the attribute as if it were false. ' + 'Either pass `false` to silence this warning, or ' + 'pass `true` if you used an empty string in earlier versions of React to indicate this attribute is true.', name); - } - } // Boolean - - - if (value && typeof value !== 'function' && typeof value !== 'symbol') { - target.push(attributeSeparator, stringToChunk(name), attributeEmptyString); - } - - return; - } - } - // fallthrough for new boolean props without the flag on - default: if ( // shouldIgnoreAttribute // We have already filtered out null/undefined and reserved words. @@ -3148,7 +3131,7 @@ function flattenOptionChildren(children) { content += child; { - if (!didWarnInvalidOptionChildren && typeof child !== 'string' && typeof child !== 'number' && (typeof child !== 'bigint' || !enableBigIntSupport)) { + if (!didWarnInvalidOptionChildren && typeof child !== 'string' && typeof child !== 'number' && typeof child !== 'bigint') { didWarnInvalidOptionChildren = true; error('Cannot infer the option value of complex children. ' + 'Pass a `value` prop or use a plain string as children to