diff --git a/CHANGELOG.md b/CHANGELOG.md index 21704e3e29f64..85d8f541245e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ Read the [React 19.2 release post](https://react.dev/blog/2025/10/01/react-19-2) - [``](https://react.dev/reference/react/Activity): A new API to hide and restore the UI and internal state of its children. - [`useEffectEvent`](https://react.dev/reference/react/useEffectEvent) is a React Hook that lets you extract non-reactive logic into an [Effect Event](https://react.dev/learn/separating-events-from-effects#declaring-an-effect-event). - [`cacheSignal`](https://react.dev/reference/react/cacheSignal) (for RSCs) lets your know when the `cache()` lifetime is over. -- [React Performance tracks](https://react.dev/reference/developer-tooling/react-performance-tracks) appear on the Performance panel’s timeline in your browser developer tools +- [React Performance tracks](https://react.dev/reference/dev-tools/react-performance-tracks) appear on the Performance panel’s timeline in your browser developer tools ### New React DOM Features diff --git a/compiler/packages/babel-plugin-react-compiler/package.json b/compiler/packages/babel-plugin-react-compiler/package.json index 8d3f1c8ae68e2..7647bcab1ca42 100644 --- a/compiler/packages/babel-plugin-react-compiler/package.json +++ b/compiler/packages/babel-plugin-react-compiler/package.json @@ -52,8 +52,8 @@ "react-dom": "0.0.0-experimental-4beb1fd8-20241118", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", - "zod": "^3.22.4 || ^4.0.0", - "zod-validation-error": "^3.0.3 || ^4.0.0" + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" }, "resolutions": { "./**/@babel/parser": "7.7.4", diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts index 450972a460710..2a117b46617ec 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts @@ -6,7 +6,7 @@ */ import * as t from '@babel/types'; -import {z} from 'zod'; +import {z} from 'zod/v4'; import { CompilerDiagnostic, CompilerError, @@ -20,7 +20,7 @@ import { tryParseExternalFunction, } from '../HIR/Environment'; import {hasOwnProperty} from '../Utils/utils'; -import {fromZodError} from 'zod-validation-error'; +import {fromZodError} from 'zod-validation-error/v4'; import {CompilerPipelineValue} from './Pipeline'; const PanicThresholdOptionsSchema = z.enum([ diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts index 7889e13c2f864..a31ef1c336ed6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -6,8 +6,8 @@ */ import * as t from '@babel/types'; -import {ZodError, z} from 'zod'; -import {fromZodError} from 'zod-validation-error'; +import {ZodError, z} from 'zod/v4'; +import {fromZodError} from 'zod-validation-error/v4'; import {CompilerError} from '../CompilerError'; import {Logger, ProgramContext} from '../Entrypoint'; import {Err, Ok, Result} from '../Utils/Result'; diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts index 4d2d4ed80d69c..41e957a54677f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts @@ -16,7 +16,7 @@ import {assertExhaustive} from '../Utils/utils'; import {Environment, ReactFunctionType} from './Environment'; import type {HookKind} from './ObjectShape'; import {Type, makeType} from './Types'; -import {z} from 'zod'; +import {z} from 'zod/v4'; import type {AliasingEffect} from '../Inference/AliasingEffects'; import {isReservedWord} from '../Utils/Keyword'; import {Err, Ok, Result} from '../Utils/Result'; diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/TypeSchema.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/TypeSchema.ts index 42c7d2d89dce1..eeaaebf7a39dc 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/TypeSchema.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/TypeSchema.ts @@ -6,7 +6,7 @@ */ import {isValidIdentifier} from '@babel/types'; -import {z} from 'zod'; +import {z} from 'zod/v4'; import {Effect, ValueKind} from '..'; import { EffectSchema, diff --git a/compiler/packages/babel-plugin-react-compiler/src/Utils/TestUtils.ts b/compiler/packages/babel-plugin-react-compiler/src/Utils/TestUtils.ts index b28879f369ada..e84c1e57aae60 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Utils/TestUtils.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Utils/TestUtils.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import {fromZodError} from 'zod-validation-error'; +import {fromZodError} from 'zod-validation-error/v4'; import {CompilerError} from '../CompilerError'; import { CompilationMode, diff --git a/compiler/packages/eslint-plugin-react-compiler/package.json b/compiler/packages/eslint-plugin-react-compiler/package.json index 6c95bf495c66d..3dd77d0e8bf39 100644 --- a/compiler/packages/eslint-plugin-react-compiler/package.json +++ b/compiler/packages/eslint-plugin-react-compiler/package.json @@ -15,8 +15,8 @@ "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", - "zod": "^3.22.4 || ^4.0.0", - "zod-validation-error": "^3.0.3 || ^4.0.0" + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" }, "devDependencies": { "@babel/preset-env": "^7.22.4", diff --git a/compiler/packages/eslint-plugin-react-compiler/tsup.config.ts b/compiler/packages/eslint-plugin-react-compiler/tsup.config.ts index 4b4f526439461..716c2ffff35e9 100644 --- a/compiler/packages/eslint-plugin-react-compiler/tsup.config.ts +++ b/compiler/packages/eslint-plugin-react-compiler/tsup.config.ts @@ -10,7 +10,14 @@ import {defineConfig} from 'tsup'; export default defineConfig({ entry: ['./src/index.ts'], outDir: './dist', - external: ['@babel/core', 'hermes-parser', 'zod', 'zod-validation-error'], + external: [ + '@babel/core', + 'hermes-parser', + 'zod', + 'zod/v4', + 'zod-validation-error', + 'zod-validation-error/v4', + ], splitting: false, sourcemap: false, dts: false, diff --git a/compiler/packages/react-compiler-healthcheck/package.json b/compiler/packages/react-compiler-healthcheck/package.json index 61825b73d8320..5c3d2f412d97e 100644 --- a/compiler/packages/react-compiler-healthcheck/package.json +++ b/compiler/packages/react-compiler-healthcheck/package.json @@ -17,8 +17,8 @@ "fast-glob": "^3.3.2", "ora": "5.4.1", "yargs": "^17.7.2", - "zod": "^3.22.4 || ^4.0.0", - "zod-validation-error": "^3.0.3 || ^4.0.0" + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" }, "devDependencies": {}, "engines": { diff --git a/compiler/packages/react-compiler-healthcheck/tsup.config.ts b/compiler/packages/react-compiler-healthcheck/tsup.config.ts index 7addc79bf909a..7d2c738dc71a6 100644 --- a/compiler/packages/react-compiler-healthcheck/tsup.config.ts +++ b/compiler/packages/react-compiler-healthcheck/tsup.config.ts @@ -18,7 +18,9 @@ export default defineConfig({ 'ora', 'yargs', 'zod', + 'zod/v4', 'zod-validation-error', + 'zod-validation-error/v4', ], splitting: false, sourcemap: false, diff --git a/compiler/packages/react-mcp-server/package.json b/compiler/packages/react-mcp-server/package.json index 4d744c1d667fb..07dc378de12e9 100644 --- a/compiler/packages/react-mcp-server/package.json +++ b/compiler/packages/react-mcp-server/package.json @@ -24,7 +24,7 @@ "html-to-text": "^9.0.5", "prettier": "^3.3.3", "puppeteer": "^24.7.2", - "zod": "^3.22.4 || ^4.0.0" + "zod": "^3.25.0 || ^4.0.0" }, "devDependencies": { "@types/html-to-text": "^9.0.4", diff --git a/compiler/packages/react-mcp-server/src/index.ts b/compiler/packages/react-mcp-server/src/index.ts index 9c47346b3c7f8..e5bd794107dc1 100644 --- a/compiler/packages/react-mcp-server/src/index.ts +++ b/compiler/packages/react-mcp-server/src/index.ts @@ -7,7 +7,7 @@ import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; -import {z} from 'zod'; +import {z} from 'zod/v4'; import {compile, type PrintedCompilerPipelineValue} from './compiler'; import { CompilerPipelineValue, diff --git a/compiler/packages/snap/package.json b/compiler/packages/snap/package.json index 60530f01dd418..085422ab83034 100644 --- a/compiler/packages/snap/package.json +++ b/compiler/packages/snap/package.json @@ -37,7 +37,9 @@ "react": "0.0.0-experimental-4beb1fd8-20241118", "react-dom": "0.0.0-experimental-4beb1fd8-20241118", "readline": "^1.3.0", - "yargs": "^17.7.1" + "yargs": "^17.7.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" }, "devDependencies": { "@babel/core": "^7.19.1", diff --git a/compiler/packages/snap/src/sprout/evaluator.ts b/compiler/packages/snap/src/sprout/evaluator.ts index 8af8487d01109..ba44f01b0aa37 100644 --- a/compiler/packages/snap/src/sprout/evaluator.ts +++ b/compiler/packages/snap/src/sprout/evaluator.ts @@ -9,8 +9,8 @@ import {render} from '@testing-library/react'; import {JSDOM} from 'jsdom'; import React, {MutableRefObject} from 'react'; import util from 'util'; -import {z} from 'zod'; -import {fromZodError} from 'zod-validation-error'; +import {z} from 'zod/v4'; +import {fromZodError} from 'zod-validation-error/v4'; import {initFbt, toJSON} from './shared-runtime'; /** diff --git a/compiler/yarn.lock b/compiler/yarn.lock index daafc705fdcda..764200c2ce9de 100644 --- a/compiler/yarn.lock +++ b/compiler/yarn.lock @@ -11505,17 +11505,17 @@ zod-to-json-schema@^3.24.1: resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz" integrity sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g== -"zod-validation-error@^3.0.3 || ^4.0.0": +"zod-validation-error@^3.5.0 || ^4.0.0": version "4.0.2" resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz#bc605eba49ce0fcd598c127fee1c236be3f22918" integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ== -"zod@^3.22.4 || ^4.0.0": - version "4.1.11" - resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.11.tgz#4aab62f76cfd45e6c6166519ba31b2ea019f75f5" - integrity sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg== - zod@^3.23.8, zod@^3.24.1: version "3.24.3" resolved "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz" integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg== + +"zod@^3.25.0 || ^4.0.0": + version "4.1.12" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.12.tgz#64f1ea53d00eab91853195653b5af9eee68970f0" + integrity sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ== diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js index 3e89624c6d3f1..05bdb1e71ed8f 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js @@ -1555,6 +1555,17 @@ const allTests = { `, errors: [useEffectEventError('onClick', false)], }, + { + code: normalizeIndent` + // Invalid because useEffectEvent is being passed down + function MyComponent({ theme }) { + return { + showNotification(theme); + })} />; + } + `, + errors: [{...useEffectEventError(null, false), line: 4}], + }, { code: normalizeIndent` // This should error even though it shares an identifier name with the below @@ -1726,6 +1737,14 @@ function classError(hook) { } function useEffectEventError(fn, called) { + if (fn === null) { + return { + message: + `React Hook "useEffectEvent" can only be called at the top level of your component.` + + ` It cannot be passed down.`, + }; + } + return { message: `\`${fn}\` is a function created with React Hook "useEffectEvent", and can only be called from ` + diff --git a/packages/eslint-plugin-react-hooks/package.json b/packages/eslint-plugin-react-hooks/package.json index a22448f11c78f..9a8f8ac353ff7 100644 --- a/packages/eslint-plugin-react-hooks/package.json +++ b/packages/eslint-plugin-react-hooks/package.json @@ -42,8 +42,8 @@ "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", - "zod": "^3.22.4 || ^4.0.0", - "zod-validation-error": "^3.0.3 || ^4.0.0" + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" }, "devDependencies": { "@babel/eslint-parser": "^7.11.4", diff --git a/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts b/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts index ba398d850dedb..4e49e96bfe68c 100644 --- a/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts +++ b/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts @@ -171,7 +171,15 @@ function isUseEffectEventIdentifier(node: Node): boolean { return node.type === 'Identifier' && node.name === 'useEffectEvent'; } -function useEffectEventError(fn: string, called: boolean): string { +function useEffectEventError(fn: string | null, called: boolean): string { + // no function identifier, i.e. it is not assigned to a variable + if (fn === null) { + return ( + `React Hook "useEffectEvent" can only be called at the top level of your component.` + + ` It cannot be passed down.` + ); + } + return ( `\`${fn}\` is a function created with React Hook "useEffectEvent", and can only be called from ` + 'Effects and Effect Events in the same component.' + @@ -772,6 +780,22 @@ const rule = { // comparison later when we exit lastEffect = node; } + + // Specifically disallow because this + // case can't be caught by `recordAllUseEffectEventFunctions` as it isn't assigned to a variable + if ( + isUseEffectEventIdentifier(nodeWithoutNamespace) && + node.parent?.type !== 'VariableDeclarator' && + // like in other hooks, calling useEffectEvent at component's top level without assignment is valid + node.parent?.type !== 'ExpressionStatement' + ) { + const message = useEffectEventError(null, false); + + context.report({ + node, + message, + }); + } }, Identifier(node) { diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 3638852c20b72..c02d8130c308e 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -1546,7 +1546,7 @@ describe('Store', () => { ▸ `); - const deepestedNodeID = agent.getIDForHostInstance(ref.current); + const deepestedNodeID = agent.getIDForHostInstance(ref.current).id; await act(() => store.toggleIsCollapsed(deepestedNodeID, false)); expect(store).toMatchInlineSnapshot(` diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index fce8fe626d443..42fbbc9648a98 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -455,7 +455,10 @@ export default class Agent extends EventEmitter<{ return renderer.getInstanceAndStyle(id); } - getIDForHostInstance(target: HostInstance): number | null { + getIDForHostInstance( + target: HostInstance, + onlySuspenseNodes?: boolean, + ): null | {id: number, rendererID: number} { if (isReactNativeEnvironment() || typeof target.nodeType !== 'number') { // In React Native or non-DOM we simply pick any renderer that has a match. for (const rendererID in this._rendererInterfaces) { @@ -463,9 +466,14 @@ export default class Agent extends EventEmitter<{ (rendererID: any) ]: any): RendererInterface); try { - const match = renderer.getElementIDForHostInstance(target); - if (match != null) { - return match; + const id = onlySuspenseNodes + ? renderer.getSuspenseNodeIDForHostInstance(target) + : renderer.getElementIDForHostInstance(target); + if (id !== null) { + return { + id: id, + rendererID: +rendererID, + }; } } catch (error) { // Some old React versions might throw if they can't find a match. @@ -478,6 +486,7 @@ export default class Agent extends EventEmitter<{ // that is registered if there isn't an exact match. let bestMatch: null | Element = null; let bestRenderer: null | RendererInterface = null; + let bestRendererID: number = 0; // Find the nearest ancestor which is mounted by a React. for (const rendererID in this._rendererInterfaces) { const renderer = ((this._rendererInterfaces[ @@ -491,6 +500,7 @@ export default class Agent extends EventEmitter<{ // Exact match we can exit early. bestMatch = nearestNode; bestRenderer = renderer; + bestRendererID = +rendererID; break; } if (bestMatch === null || bestMatch.contains(nearestNode)) { @@ -498,12 +508,21 @@ export default class Agent extends EventEmitter<{ // so the new match is a deeper and therefore better match. bestMatch = nearestNode; bestRenderer = renderer; + bestRendererID = +rendererID; } } } if (bestRenderer != null && bestMatch != null) { try { - return bestRenderer.getElementIDForHostInstance(bestMatch); + const id = onlySuspenseNodes + ? bestRenderer.getSuspenseNodeIDForHostInstance(bestMatch) + : bestRenderer.getElementIDForHostInstance(bestMatch); + if (id !== null) { + return { + id, + rendererID: bestRendererID, + }; + } } catch (error) { // Some old React versions might throw if they can't find a match. // If so we should ignore it... @@ -514,65 +533,14 @@ export default class Agent extends EventEmitter<{ } getComponentNameForHostInstance(target: HostInstance): string | null { - // We duplicate this code from getIDForHostInstance to avoid an object allocation. - if (isReactNativeEnvironment() || typeof target.nodeType !== 'number') { - // In React Native or non-DOM we simply pick any renderer that has a match. - for (const rendererID in this._rendererInterfaces) { - const renderer = ((this._rendererInterfaces[ - (rendererID: any) - ]: any): RendererInterface); - try { - const id = renderer.getElementIDForHostInstance(target); - if (id) { - return renderer.getDisplayNameForElementID(id); - } - } catch (error) { - // Some old React versions might throw if they can't find a match. - // If so we should ignore it... - } - } - return null; - } else { - // In the DOM we use a smarter mechanism to find the deepest a DOM node - // that is registered if there isn't an exact match. - let bestMatch: null | Element = null; - let bestRenderer: null | RendererInterface = null; - // Find the nearest ancestor which is mounted by a React. - for (const rendererID in this._rendererInterfaces) { - const renderer = ((this._rendererInterfaces[ - (rendererID: any) - ]: any): RendererInterface); - const nearestNode: null | Element = renderer.getNearestMountedDOMNode( - (target: any), - ); - if (nearestNode !== null) { - if (nearestNode === target) { - // Exact match we can exit early. - bestMatch = nearestNode; - bestRenderer = renderer; - break; - } - if (bestMatch === null || bestMatch.contains(nearestNode)) { - // If this is the first match or the previous match contains the new match, - // so the new match is a deeper and therefore better match. - bestMatch = nearestNode; - bestRenderer = renderer; - } - } - } - if (bestRenderer != null && bestMatch != null) { - try { - const id = bestRenderer.getElementIDForHostInstance(bestMatch); - if (id) { - return bestRenderer.getDisplayNameForElementID(id); - } - } catch (error) { - // Some old React versions might throw if they can't find a match. - // If so we should ignore it... - } - } - return null; + const match = this.getIDForHostInstance(target); + if (match !== null) { + const renderer = ((this._rendererInterfaces[ + (match.rendererID: any) + ]: any): RendererInterface); + return renderer.getDisplayNameForElementID(match.id); } + return null; } getBackendVersion: () => void = () => { @@ -971,9 +939,9 @@ export default class Agent extends EventEmitter<{ }; selectNode(target: HostInstance): void { - const id = this.getIDForHostInstance(target); - if (id !== null) { - this._bridge.send('selectElement', id); + const match = this.getIDForHostInstance(target); + if (match !== null) { + this._bridge.send('selectElement', match.id); } } diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index b7fe41b96c5b4..de178909b29ad 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2693,10 +2693,10 @@ export function attach( pushOperation(rects.length); for (let i = 0; i < rects.length; ++i) { const rect = rects[i]; - pushOperation(Math.round(rect.x)); - pushOperation(Math.round(rect.y)); - pushOperation(Math.round(rect.width)); - pushOperation(Math.round(rect.height)); + pushOperation(Math.round(rect.x * 1000)); + pushOperation(Math.round(rect.y * 1000)); + pushOperation(Math.round(rect.width * 1000)); + pushOperation(Math.round(rect.height * 1000)); } } } @@ -2765,10 +2765,10 @@ export function attach( pushOperation(rects.length); for (let i = 0; i < rects.length; ++i) { const rect = rects[i]; - pushOperation(Math.round(rect.x)); - pushOperation(Math.round(rect.y)); - pushOperation(Math.round(rect.width)); - pushOperation(Math.round(rect.height)); + pushOperation(Math.round(rect.x * 1000)); + pushOperation(Math.round(rect.y * 1000)); + pushOperation(Math.round(rect.width * 1000)); + pushOperation(Math.round(rect.height * 1000)); } } } @@ -5793,7 +5793,28 @@ export function attach( return null; } if (devtoolsInstance.kind === FIBER_INSTANCE) { - return getDisplayNameForFiber(devtoolsInstance.data); + const fiber = devtoolsInstance.data; + if (fiber.tag === HostRoot) { + // The only reason you'd inspect a HostRoot is to show it as a SuspenseNode. + return 'Initial Paint'; + } + if (fiber.tag === SuspenseComponent || fiber.tag === ActivityComponent) { + // For Suspense and Activity components, we can show a better name + // by using the name prop or their owner. + const props = fiber.memoizedProps; + if (props.name != null) { + return props.name; + } + const owner = getUnfilteredOwner(fiber); + if (owner != null) { + if (typeof owner.tag === 'number') { + return getDisplayNameForFiber((owner: any)); + } else { + return owner.name || ''; + } + } + } + return getDisplayNameForFiber(fiber); } else { return devtoolsInstance.data.name || ''; } @@ -5834,6 +5855,28 @@ export function attach( return null; } + function getSuspenseNodeIDForHostInstance( + publicInstance: HostInstance, + ): number | null { + const instance = publicInstanceToDevToolsInstanceMap.get(publicInstance); + if (instance !== undefined) { + // Pick nearest unfiltered SuspenseNode instance. + let suspenseInstance = instance; + while ( + suspenseInstance.suspenseNode === null || + suspenseInstance.kind === FILTERED_FIBER_INSTANCE + ) { + if (suspenseInstance.parent === null) { + // We shouldn't get here since we'll always have a suspenseNode at the root. + return null; + } + suspenseInstance = suspenseInstance.parent; + } + return suspenseInstance.id; + } + return null; + } + function getElementAttributeByPath( id: number, path: Array, @@ -8630,6 +8673,7 @@ export function attach( getDisplayNameForElementID, getNearestMountedDOMNode, getElementIDForHostInstance, + getSuspenseNodeIDForHostInstance, getInstanceAndStyle, getOwnersList, getPathForElement, diff --git a/packages/react-devtools-shared/src/backend/flight/renderer.js b/packages/react-devtools-shared/src/backend/flight/renderer.js index d0dc9094334eb..e26525a0d607a 100644 --- a/packages/react-devtools-shared/src/backend/flight/renderer.js +++ b/packages/react-devtools-shared/src/backend/flight/renderer.js @@ -169,6 +169,9 @@ export function attach( getElementIDForHostInstance() { return null; }, + getSuspenseNodeIDForHostInstance() { + return null; + }, getInstanceAndStyle() { return { instance: null, diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 1262a8d4647a6..ccd9cdac3e02e 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -1269,6 +1269,9 @@ export function attach( getDisplayNameForElementID, getNearestMountedDOMNode, getElementIDForHostInstance, + getSuspenseNodeIDForHostInstance(id: number): null { + return null; + }, getInstanceAndStyle, findHostInstancesForElementID: (id: number) => { const hostInstance = findHostInstanceForInternalID(id); diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index 1052dc9d75b14..67d6a5f834bc8 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -427,6 +427,7 @@ export type RendererInterface = { getComponentStack?: GetComponentStack, getNearestMountedDOMNode: (component: Element) => Element | null, getElementIDForHostInstance: GetElementIDForHostInstance, + getSuspenseNodeIDForHostInstance: GetElementIDForHostInstance, getDisplayNameForElementID: GetDisplayNameForElementID, getInstanceAndStyle(id: number): InstanceAndStyle, getProfilingData(): ProfilingDataBackend, diff --git a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js index 894c4fba94404..b67b3964ed597 100644 --- a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js +++ b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js @@ -20,6 +20,7 @@ import type {RendererInterface} from '../../types'; // That is done by the React Native Inspector component. let iframesListeningTo: Set = new Set(); +let inspectOnlySuspenseNodes = false; export default function setupHighlighter( bridge: BackendBridge, @@ -33,7 +34,8 @@ export default function setupHighlighter( bridge.addListener('startInspectingHost', startInspectingHost); bridge.addListener('stopInspectingHost', stopInspectingHost); - function startInspectingHost() { + function startInspectingHost(onlySuspenseNodes: boolean) { + inspectOnlySuspenseNodes = onlySuspenseNodes; registerListenersOnWindow(window); } @@ -363,9 +365,37 @@ export default function setupHighlighter( } } - // Don't pass the name explicitly. - // It will be inferred from DOM tag and Fiber owner. - showOverlay([target], null, agent, false); + if (inspectOnlySuspenseNodes) { + // For Suspense nodes we want to highlight not the actual target but the nodes + // that are the root of the Suspense node. + // TODO: Consider if we should just do the same for other elements because the + // hovered node might just be one child of many in the Component. + const match = agent.getIDForHostInstance( + target, + inspectOnlySuspenseNodes, + ); + if (match !== null) { + const renderer = agent.rendererInterfaces[match.rendererID]; + if (renderer == null) { + console.warn( + `Invalid renderer id "${match.rendererID}" for element "${match.id}"`, + ); + return; + } + highlightHostInstance({ + displayName: renderer.getDisplayNameForElementID(match.id), + hideAfterTimeout: false, + id: match.id, + openBuiltinElementsPanel: false, + rendererID: match.rendererID, + scrollIntoView: false, + }); + } + } else { + // Don't pass the name explicitly. + // It will be inferred from DOM tag and Fiber owner. + showOverlay([target], null, agent, false); + } } function onPointerUp(event: MouseEvent) { @@ -374,9 +404,9 @@ export default function setupHighlighter( } const selectElementForNode = (node: HTMLElement) => { - const id = agent.getIDForHostInstance(node); - if (id !== null) { - bridge.send('selectElement', id); + const match = agent.getIDForHostInstance(node, inspectOnlySuspenseNodes); + if (match !== null) { + bridge.send('selectElement', match.id); } }; diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index 3162dc215ff0a..683b341920244 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -266,7 +266,7 @@ type FrontendEvents = { savedPreferences: [SavedPreferencesParams], setTraceUpdatesEnabled: [boolean], shutdown: [], - startInspectingHost: [], + startInspectingHost: [boolean], startProfiling: [StartProfilingParams], stopInspectingHost: [], scrollToHostInstance: [ScrollToHostInstance], diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 86961f5bd91fb..f1aa61bfe9b86 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -1587,10 +1587,10 @@ export default class Store extends EventEmitter<{ } else { rects = []; for (let rectIndex = 0; rectIndex < numRects; rectIndex++) { - const x = operations[i + 0]; - const y = operations[i + 1]; - const width = operations[i + 2]; - const height = operations[i + 3]; + const x = operations[i + 0] / 1000; + const y = operations[i + 1] / 1000; + const width = operations[i + 2] / 1000; + const height = operations[i + 3] / 1000; rects.push({x, y, width, height}); i += 4; } @@ -1763,10 +1763,10 @@ export default class Store extends EventEmitter<{ } else { nextRects = []; for (let rectIndex = 0; rectIndex < numRects; rectIndex++) { - const x = operations[i + 0]; - const y = operations[i + 1]; - const width = operations[i + 2]; - const height = operations[i + 3]; + const x = operations[i + 0] / 1000; + const y = operations[i + 1] / 1000; + const width = operations[i + 2] / 1000; + const height = operations[i + 3] / 1000; nextRects.push({x, y, width, height}); diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectHostNodesToggle.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectHostNodesToggle.js index 17a7b049cc9b3..1b8fd54dc5ce0 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectHostNodesToggle.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectHostNodesToggle.js @@ -14,7 +14,11 @@ import Toggle from '../Toggle'; import ButtonIcon from '../ButtonIcon'; import {logEvent} from 'react-devtools-shared/src/Logger'; -export default function InspectHostNodesToggle(): React.Node { +export default function InspectHostNodesToggle({ + onlySuspenseNodes, +}: { + onlySuspenseNodes?: boolean, +}): React.Node { const [isInspecting, setIsInspecting] = useState(false); const bridge = useContext(BridgeContext); @@ -24,7 +28,7 @@ export default function InspectHostNodesToggle(): React.Node { if (isChecked) { logEvent({event_name: 'inspect-element-button-clicked'}); - bridge.send('startInspectingHost'); + bridge.send('startInspectingHost', !!onlySuspenseNodes); } else { bridge.send('stopInspectingHost'); } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js index 5f07bb61001ee..c2a131916504c 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -36,12 +36,14 @@ function ScaledRect({ rect, visible, suspended, + adjust, ...props }: { className: string, rect: Rect, visible: boolean, suspended: boolean, + adjust?: boolean, ... }): React$Node { const viewBox = useContext(ViewBox); @@ -57,8 +59,9 @@ function ScaledRect({ data-visible={visible} data-suspended={suspended} style={{ - width, - height, + // Shrink one pixel so that the bottom outline will line up with the top outline of the next one. + width: adjust ? 'calc(' + width + ' - 1px)' : width, + height: adjust ? 'calc(' + height + ' - 1px)' : height, top: y, left: x, }} @@ -160,6 +163,7 @@ function SuspenseRects({ className={styles.SuspenseRectsRect} rect={rect} data-highlighted={selected} + adjust={true} onClick={handleClick} onDoubleClick={handleDoubleClick} onPointerOver={handlePointerOver} diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js index 9ade19c33075b..1344faf0a29a8 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -14,6 +14,7 @@ import { useLayoutEffect, useReducer, useRef, + Fragment, } from 'react'; import { @@ -21,6 +22,7 @@ import { localStorageSetItem, } from 'react-devtools-shared/src/storage'; import ButtonIcon, {type IconType} from '../ButtonIcon'; +import InspectHostNodesToggle from '../Components/InspectHostNodesToggle'; import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBoundary'; import InspectedElement from '../Components/InspectedElement'; import portaledContent from '../portaledContent'; @@ -156,6 +158,7 @@ function ToggleInspectedElement({ } function SuspenseTab(_: {}) { + const store = useContext(StoreContext); const {hideSettings} = useContext(OptionsContext); const [state, dispatch] = useReducer( layoutReducer, @@ -367,6 +370,12 @@ function SuspenseTab(_: {}) { ) : ( )} + {store.supportsClickToInspect && ( + + +
+ + )}
diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index 9b70812134288..4712397632c11 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -160,9 +160,7 @@ function SuspenseTimelineInput() { onClick={skipForward}> -
+
{ - // TODO (named hooks) Re-think this; the main case where it matters is when there's no source-maps, - // because then we need to parse the full source file as an AST. - if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) { - throw Error('Source code too large to parse'); - } + (runtimeSourceURL && !runtimeSourceURL.startsWith(' { + // TODO (named hooks) Re-think this; the main case where it matters is when there's no source-maps, + // because then we need to parse the full source file as an AST. + if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) { + throw Error('Source code too large to parse'); + } - if (__DEBUG__) { - console.groupCollapsed( - `loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`, - ); - console.log(runtimeSourceCode); - console.groupEnd(); - } + if (__DEBUG__) { + console.groupCollapsed( + `loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`, + ); + console.log(runtimeSourceCode); + console.groupEnd(); + } - return runtimeSourceCode; - }); + return runtimeSourceCode; + }) + : Promise.reject(new Error('Empty url'))); dedupedFetchPromises.set(runtimeSourceURL, fetchPromise); setterPromises.push( diff --git a/packages/react-devtools-shared/src/symbolicateSource.js b/packages/react-devtools-shared/src/symbolicateSource.js index 092b1f8187ad1..ebcf161dc963a 100644 --- a/packages/react-devtools-shared/src/symbolicateSource.js +++ b/packages/react-devtools-shared/src/symbolicateSource.js @@ -52,6 +52,9 @@ export async function symbolicateSource( lineNumber: number, // 1-based columnNumber: number, // 1-based ): Promise { + if (!sourceURL || sourceURL.startsWith(' null); if (resource == null) { return null; diff --git a/packages/react/src/jsx/ReactJSXElement.js b/packages/react/src/jsx/ReactJSXElement.js index a77c4c3cdbf10..e23c998da511b 100644 --- a/packages/react/src/jsx/ReactJSXElement.js +++ b/packages/react/src/jsx/ReactJSXElement.js @@ -57,6 +57,14 @@ function getOwner() { return null; } +// v8 (Chromium, Node.js) defaults to 10 +// SpiderMonkey (Firefox) does not support Error.stackTraceLimit +// JSC (Safari) defaults to 100 +// The lower the limit, the more likely we'll not reach react_stack_bottom_frame +// The higher the limit, the slower Error() is when not inspecting with a debugger. +// When inspecting with a debugger, Error.stackTraceLimit has no impact on Error() performance (in v8). +const ownerStackTraceLimit = 10; + /** @noinline */ function UnknownOwner() { /** @noinline */ @@ -352,15 +360,24 @@ export function jsxProdSignatureRunningInDevWithDynamicChildren( const trackActualOwner = __DEV__ && ReactSharedInternals.recentlyCreatedOwnerStacks++ < ownerStackLimit; + let debugStackDEV = false; + if (__DEV__) { + if (trackActualOwner) { + const previousStackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = ownerStackTraceLimit; + debugStackDEV = Error('react-stack-top-frame'); + Error.stackTraceLimit = previousStackTraceLimit; + } else { + debugStackDEV = unknownOwnerDebugStack; + } + } + return jsxDEVImpl( type, config, maybeKey, isStaticChildren, - __DEV__ && - (trackActualOwner - ? Error('react-stack-top-frame') - : unknownOwnerDebugStack), + debugStackDEV, __DEV__ && (trackActualOwner ? createTask(getTaskName(type)) @@ -379,15 +396,23 @@ export function jsxProdSignatureRunningInDevWithStaticChildren( const trackActualOwner = __DEV__ && ReactSharedInternals.recentlyCreatedOwnerStacks++ < ownerStackLimit; + let debugStackDEV = false; + if (__DEV__) { + if (trackActualOwner) { + const previousStackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = ownerStackTraceLimit; + debugStackDEV = Error('react-stack-top-frame'); + Error.stackTraceLimit = previousStackTraceLimit; + } else { + debugStackDEV = unknownOwnerDebugStack; + } + } return jsxDEVImpl( type, config, maybeKey, isStaticChildren, - __DEV__ && - (trackActualOwner - ? Error('react-stack-top-frame') - : unknownOwnerDebugStack), + debugStackDEV, __DEV__ && (trackActualOwner ? createTask(getTaskName(type)) @@ -408,15 +433,23 @@ export function jsxDEV(type, config, maybeKey, isStaticChildren) { const trackActualOwner = __DEV__ && ReactSharedInternals.recentlyCreatedOwnerStacks++ < ownerStackLimit; + let debugStackDEV = false; + if (__DEV__) { + if (trackActualOwner) { + const previousStackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = ownerStackTraceLimit; + debugStackDEV = Error('react-stack-top-frame'); + Error.stackTraceLimit = previousStackTraceLimit; + } else { + debugStackDEV = unknownOwnerDebugStack; + } + } return jsxDEVImpl( type, config, maybeKey, isStaticChildren, - __DEV__ && - (trackActualOwner - ? Error('react-stack-top-frame') - : unknownOwnerDebugStack), + debugStackDEV, __DEV__ && (trackActualOwner ? createTask(getTaskName(type)) @@ -667,15 +700,23 @@ export function createElement(type, config, children) { const trackActualOwner = __DEV__ && ReactSharedInternals.recentlyCreatedOwnerStacks++ < ownerStackLimit; + let debugStackDEV = false; + if (__DEV__) { + if (trackActualOwner) { + const previousStackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = ownerStackTraceLimit; + debugStackDEV = Error('react-stack-top-frame'); + Error.stackTraceLimit = previousStackTraceLimit; + } else { + debugStackDEV = unknownOwnerDebugStack; + } + } return ReactElement( type, key, props, getOwner(), - __DEV__ && - (trackActualOwner - ? Error('react-stack-top-frame') - : unknownOwnerDebugStack), + debugStackDEV, __DEV__ && (trackActualOwner ? createTask(getTaskName(type)) diff --git a/scripts/flags/flags.js b/scripts/flags/flags.js index 1130ce80bb073..a02b4d84b341d 100644 --- a/scripts/flags/flags.js +++ b/scripts/flags/flags.js @@ -1,3 +1,9 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ 'use strict'; const babel = require('@babel/register'); diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index c66b43797f180..0a7b17ec2cc7f 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -1255,7 +1255,9 @@ const bundles = [ '@babel/core', 'hermes-parser', 'zod', + 'zod/v4', 'zod-validation-error', + 'zod-validation-error/v4', 'crypto', 'util', ], diff --git a/yarn.lock b/yarn.lock index 73519b9f695a0..ad8a3f0085cbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18245,12 +18245,12 @@ zip-stream@^2.1.2: compress-commons "^2.1.1" readable-stream "^3.4.0" -"zod-validation-error@^3.0.3 || ^4.0.0": +"zod-validation-error@^3.5.0 || ^4.0.0": version "4.0.2" resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz#bc605eba49ce0fcd598c127fee1c236be3f22918" integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ== -"zod@^3.22.4 || ^4.0.0": - version "4.1.11" - resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.11.tgz#4aab62f76cfd45e6c6166519ba31b2ea019f75f5" - integrity sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg== +"zod@^3.25.0 || ^4.0.0": + version "4.1.12" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.12.tgz#64f1ea53d00eab91853195653b5af9eee68970f0" + integrity sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==