From 71ecaf89904a352e751ee43b00253c9927fb7183 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 16 Jul 2026 14:50:17 +0200 Subject: [PATCH 1/7] [test] Add Flight regression test for async debug info surviving Promise GC (#37037) Co-authored-by: Claude Fable 5 --- .../ReactFlightAsyncDebugInfo-test.js | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js index f659c2bba83..c11a2474e67 100644 --- a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js +++ b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js @@ -3932,4 +3932,232 @@ describe('ReactFlightAsyncDebugInfo', () => { `); } }); + + it('does not lose I/O debug info when intermediate promises are garbage collected', async () => { + // Get a handle on the garbage collector without running the test with --expose-gc. + const v8 = require('v8'); + const vm = require('vm'); + v8.setFlagsFromString('--expose-gc'); + const gc = vm.runInNewContext('gc'); + v8.setFlagsFromString('--no-expose-gc'); + + // The global setImmediate is patched to setTimeout in this test which + // registers as new I/O in async_hooks. Use the real setImmediate for + // yielding to the event loop while waiting for GC so that it doesn't add + // I/O entries to the debug info of the component below. + const {setImmediate: realSetImmediate} = require('timers'); + function tick() { + return new Promise(resolve => realSetImmediate(resolve)); + } + + let ioPromiseRef = null; + let collectedIntermediatePromises = false; + + async function getData(text) { + const promise = delay(1); + ioPromiseRef = new WeakRef(promise); + await promise; + return text.toUpperCase(); + } + + async function waitForGarbageCollection(weakRef) { + // deref() keeps the target alive until the end of the current task, so + // check it on a later tick than the gc() call. + let collected = false; + for (let i = 0; !collected && i < 100; i++) { + gc(); + await tick(); + collected = weakRef.deref() === undefined; + await tick(); + } + // The destroy() hooks of collected promises fire asynchronously. Yield + // a few more times to ensure they have all run. + for (let i = 0; i < 5; i++) { + gc(); + await tick(); + } + return collected; + } + + async function Component() { + const result = await getData('hi'); + // At this point the intermediate promises (the delay() promise and + // getData's own async function promise) are unreachable. The async + // debug info graph holds them only weakly through WeakRefs. Force them + // to be garbage collected, which fires their async_hooks destroy() + // hooks, before this component resolves, which is when React walks the + // async graph to emit the component's debug info. + collectedIntermediatePromises = + await waitForGarbageCollection(ioPromiseRef); + return result; + } + + const stream = ReactServerDOMServer.renderToPipeableStream(); + + const readable = new Stream.PassThrough(streamOptions); + + const result = ReactServerDOMClient.createFromNodeStream(readable, { + moduleMap: {}, + moduleLoading: {}, + }); + stream.pipe(readable); + + expect(await result).toBe('HI'); + // If this fails, the GC helper above no longer actually collects the + // promises and this test is not testing anything. + expect(collectedIntermediatePromises).toBe(true); + + await finishLoadingStream(readable); + if ( + __DEV__ && + gate( + flags => + flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo, + ) + ) { + const debugInfo = getDebugInfo(result); + // The I/O entry for delay() must survive the garbage collection of the + // intermediate promises. The graph nodes are intentionally held + // strongly (only the promises themselves are held weakly through + // WeakRefs) so that the originating I/O is still reachable when the + // debug info is emitted after the promises are gone. + expect(debugInfo).toContainEqual( + expect.objectContaining({ + awaited: expect.objectContaining({name: 'delay'}), + }), + ); + expect(debugInfo).toMatchInlineSnapshot(` + [ + { + "time": 0, + }, + { + "env": "Server", + "key": null, + "name": "Component", + "props": {}, + "stack": [ + [ + "Object.", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 3995, + 109, + 3936, + 87, + ], + ], + }, + { + "time": 0, + }, + { + "awaited": { + "end": 0, + "env": "Server", + "name": "delay", + "owner": { + "env": "Server", + "key": null, + "name": "Component", + "props": {}, + "stack": [ + [ + "Object.", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 3995, + 109, + 3936, + 87, + ], + ], + }, + "stack": [ + [ + "delay", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 87, + 12, + 86, + 3, + ], + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 3957, + 21, + 3956, + 5, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 3983, + 26, + 3982, + 5, + ], + ], + "start": 0, + "value": { + "value": undefined, + }, + }, + "env": "Server", + "owner": { + "env": "Server", + "key": null, + "name": "Component", + "props": {}, + "stack": [ + [ + "Object.", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 3995, + 109, + 3936, + 87, + ], + ], + }, + "stack": [ + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 3959, + 7, + 3956, + 5, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 3983, + 26, + 3982, + 5, + ], + ], + }, + { + "time": 0, + }, + { + "time": 0, + }, + { + "awaited": { + "byteSize": 0, + "end": 0, + "name": "rsc stream", + "owner": null, + "start": 0, + "value": { + "value": "stream", + }, + }, + }, + ] + `); + } + }); }); From 0e516d326c9c3922c2584e42698b53ae4ab0d7e5 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 16 Jul 2026 15:04:50 +0200 Subject: [PATCH 2/7] [devtools] Document that Store consistency throws must not be worked around (#37035) Co-authored-by: Claude Fable 5 --- .../src/devtools/store.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 557020620e9..91b3586b816 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -608,6 +608,7 @@ export default class Store extends EventEmitter<{ root = this._idToElement.get(rootID); if (root === undefined) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Couldn't find root with id "${rootID}": no matching node was found in the Store.`, @@ -644,6 +645,7 @@ export default class Store extends EventEmitter<{ const child = this._idToElement.get(childID); if (child === undefined) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Couldn't child element with id "${childID}": no matching node was found in the Store.`, @@ -1431,6 +1433,7 @@ export default class Store extends EventEmitter<{ i += 3; if (this._idToElement.has(id)) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Cannot add node "${id}" because a node with that id is already in the Store.`, @@ -1541,6 +1544,7 @@ export default class Store extends EventEmitter<{ const parentElement = this._idToElement.get(parentID); if (parentElement === undefined) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Cannot add child "${id}" to parent "${parentID}" because parent node was not found in the Store.`, @@ -1617,6 +1621,7 @@ export default class Store extends EventEmitter<{ const element = this._idToElement.get(id); if (element === undefined) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Cannot remove node "${id}" because no matching node was found in the Store.`, @@ -1630,6 +1635,7 @@ export default class Store extends EventEmitter<{ const {children, ownerID, parentID, weight} = element; if (children.length > 0) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error(`Node "${id}" was removed before its children.`), ); @@ -1657,6 +1663,7 @@ export default class Store extends EventEmitter<{ parentElement = this._idToElement.get(parentID); if (parentElement === undefined) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Cannot remove node "${id}" from parent "${parentID}" because no matching node was found in the Store.`, @@ -1696,6 +1703,7 @@ export default class Store extends EventEmitter<{ const element = this._idToElement.get(id); if (element === undefined) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Cannot reorder children for node "${id}" because no matching node was found in the Store.`, @@ -1707,6 +1715,7 @@ export default class Store extends EventEmitter<{ const children = element.children; if (children.length !== numChildren) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Children cannot be added or removed during a reorder operation.`, @@ -1824,6 +1833,7 @@ export default class Store extends EventEmitter<{ let name = stringTable[nameStringID]; if (this._idToSuspense.has(id)) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Cannot add suspense node "${id}" because a suspense node with that id is already in the Store.`, @@ -1876,6 +1886,7 @@ export default class Store extends EventEmitter<{ if (parentID !== 0) { const parentSuspense = this._idToSuspense.get(parentID); if (parentSuspense === undefined) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Cannot add suspense child "${id}" to parent suspense "${parentID}" because parent suspense node was not found in the Store.`, @@ -1912,6 +1923,7 @@ export default class Store extends EventEmitter<{ const suspense = this._idToSuspense.get(id); if (suspense === undefined) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Cannot remove suspense node "${id}" because no matching node was found in the Store.`, @@ -1925,6 +1937,7 @@ export default class Store extends EventEmitter<{ const {children, parentID, rects} = suspense; if (children.length > 0) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error(`Suspense node "${id}" was removed before its children.`), ); @@ -1954,6 +1967,7 @@ export default class Store extends EventEmitter<{ parentSuspense = this._idToSuspense.get(parentID); if (parentSuspense === undefined) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Cannot remove suspense node "${id}" from parent "${parentID}" because no matching node was found in the Store.`, @@ -1965,6 +1979,7 @@ export default class Store extends EventEmitter<{ const index = parentSuspense.children.indexOf(id); if (index === -1) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Cannot remove suspense node "${id}" from parent "${parentID}" because it is not a child of the parent.`, @@ -1985,6 +2000,7 @@ export default class Store extends EventEmitter<{ const suspense = this._idToSuspense.get(id); if (suspense === undefined) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Cannot reorder children for suspense node "${id}" because no matching node was found in the Store.`, @@ -1996,6 +2012,7 @@ export default class Store extends EventEmitter<{ const children = suspense.children; if (children.length !== numChildren) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Suspense children cannot be added or removed during a reorder operation.`, @@ -2036,6 +2053,7 @@ export default class Store extends EventEmitter<{ const suspense = this._idToSuspense.get(id); if (suspense === undefined) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Cannot set rects for suspense node "${id}" because no matching node was found in the Store.`, @@ -2123,6 +2141,7 @@ export default class Store extends EventEmitter<{ const suspense = this._idToSuspense.get(id); if (suspense === undefined) { + // We should never reach this. This is a bug in the backend renderer. this._throwAndEmitError( Error( `Cannot update suspenders of suspense node "${id}" because no matching node was found in the Store.`, From cec5a9bd9cb55ac20320ce188d6b127d3bbedc6b Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:21:39 +0100 Subject: [PATCH 3/7] Enable enableEffectEventMutationPhase everywhere (#37039) Enable it first, will be removed later in https://github.com/react/react/pull/37013, once it gets to stable. See https://github.com/react/react/pull/35548 for context on the gated changes. --- packages/shared/ReactFeatureFlags.js | 2 +- packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js | 1 - packages/shared/forks/ReactFeatureFlags.native-fb.js | 2 +- packages/shared/forks/ReactFeatureFlags.native-oss.js | 2 +- packages/shared/forks/ReactFeatureFlags.test-renderer.js | 2 +- .../shared/forks/ReactFeatureFlags.test-renderer.native-fb.js | 2 +- packages/shared/forks/ReactFeatureFlags.test-renderer.www.js | 2 +- packages/shared/forks/ReactFeatureFlags.www-dynamic.js | 2 -- packages/shared/forks/ReactFeatureFlags.www.js | 3 ++- 9 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index f8021edf00b..e596bcb0cb4 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -127,7 +127,7 @@ export const alwaysThrottleRetries: boolean = true; // Gate whether useEffectEvent uses the mutation phase (true) or before-mutation // phase (false) for updating event function references. -export const enableEffectEventMutationPhase: boolean = false; +export const enableEffectEventMutationPhase: boolean = true; export const passChildrenWhenCloningPersistedNodes: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index 864909b4c22..b81f9db1053 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js @@ -23,6 +23,5 @@ export const passChildrenWhenCloningPersistedNodes = __VARIANT__; export const enableFragmentRefs = __VARIANT__; export const enableFragmentRefsScrollIntoView = __VARIANT__; export const enableFragmentRefsInstanceHandles = __VARIANT__; -export const enableEffectEventMutationPhase = __VARIANT__; export const enableFragmentRefsTextNodes = __VARIANT__; export const enableViewTransitionForPersistenceMode = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 92162a835f7..df30e0985eb 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -20,7 +20,6 @@ const dynamicFlags: DynamicExportsType = dynamicFlagsUntyped as any; // the exports object every time a flag is read. export const { alwaysThrottleRetries, - enableEffectEventMutationPhase, enableObjectFiber, passChildrenWhenCloningPersistedNodes, enableFragmentRefs, @@ -43,6 +42,7 @@ export const enableAsyncDebugInfo: boolean = true; export const enableAsyncIterableChildren: boolean = false; export const enableCPUSuspense: boolean = true; export const enableCreateEventHandleAPI: boolean = false; +export const enableEffectEventMutationPhase: boolean = true; export const enableMoveBefore: boolean = true; export const enableFizzExternalRuntime: boolean = true; export const enableInfiniteRenderLoopDetection: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 9e0adb4458c..3a4018e3ad6 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -44,7 +44,7 @@ export const enablePerformanceIssueReporting: boolean = false; export const enableSchedulingProfiler: boolean = !enableComponentPerformanceTrack && __PROFILE__; export const enableScopeAPI: boolean = false; -export const enableEffectEventMutationPhase: boolean = false; +export const enableEffectEventMutationPhase: boolean = true; export const enableSuspenseAvoidThisFallback: boolean = false; export const enableSuspenseCallback: boolean = false; export const enableTaint: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 564299aaef4..d2c644edd1b 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -54,7 +54,7 @@ export const disableClientCache: boolean = true; export const enableInfiniteRenderLoopDetection: boolean = false; export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false; -export const enableEffectEventMutationPhase: boolean = false; +export const enableEffectEventMutationPhase: boolean = true; export const enableYieldingBeforePassive: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 8608a24c32e..15a0c6dded7 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -41,7 +41,7 @@ export const enableSchedulingProfiler = __PROFILE__; export const enableComponentPerformanceTrack = false; export const enablePerformanceIssueReporting = false; export const enableScopeAPI = false; -export const enableEffectEventMutationPhase = false; +export const enableEffectEventMutationPhase = true; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseCallback = false; export const enableTaint = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 87edb399a90..5a4b5650189 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -60,7 +60,7 @@ export const enableReactTestRendererWarning: boolean = false; export const disableLegacyMode: boolean = true; export const enableObjectFiber: boolean = false; -export const enableEffectEventMutationPhase: boolean = false; +export const enableEffectEventMutationPhase: boolean = true; export const enableYieldingBeforePassive: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index 7b3ce7f31cf..59bda0e3c2b 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -40,8 +40,6 @@ export const enableFragmentRefsTextNodes: boolean = __VARIANT__; export const enableInternalInstanceMap: boolean = __VARIANT__; export const enableParallelTransitions: boolean = __VARIANT__; -export const enableEffectEventMutationPhase: boolean = __VARIANT__; - // TODO: These flags are hard-coded to the default values used in open source. // Update the tests so that they pass in either mode, then set these // to __VARIANT__. diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 8bb27c71e9d..3bb77341f0a 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -18,7 +18,6 @@ export const { alwaysThrottleRetries, disableLegacyContextForFunctionComponents, disableSchedulerTimeoutInWorkLoop, - enableEffectEventMutationPhase, enableInfiniteRenderLoopDetection, enableInfiniteRenderLoopDetectionForceThrow, enableNoCloningMemoCache, @@ -81,6 +80,8 @@ export const disableCommentsAsDOMContainers: boolean = false; export const enableCreateEventHandleAPI: boolean = true; +export const enableEffectEventMutationPhase: boolean = true; + export const enableScopeAPI: boolean = true; export const enableSuspenseCallback: boolean = true; From 95f4603e4f8d28c4094a5bd99509af297abb1cb6 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:42:37 +0100 Subject: [PATCH 4/7] [react-devtools-cdt-mcp] Throw on import from unsupported environment (#36975) This add an additional validation that the package is imported in a browser-like environment. If not, we should be explicit with the user and throw an error. --- .../src/__tests__/DevToolsCdtMcp-test.js | 24 +++++++++++++++++++ packages/react-devtools-cdt-mcp/src/index.js | 15 +++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/react-devtools-cdt-mcp/src/__tests__/DevToolsCdtMcp-test.js b/packages/react-devtools-cdt-mcp/src/__tests__/DevToolsCdtMcp-test.js index c2db824fb87..ab792df9a88 100644 --- a/packages/react-devtools-cdt-mcp/src/__tests__/DevToolsCdtMcp-test.js +++ b/packages/react-devtools-cdt-mcp/src/__tests__/DevToolsCdtMcp-test.js @@ -94,6 +94,30 @@ describe('react-devtools-cdt-mcp', () => { expect(globalThis.__dtmcp).toBeUndefined(); }); + it('throws when the auto entry is imported outside an event target', () => { + const originalAddEventListener = globalThis.addEventListener; + const originalRemoveEventListener = globalThis.removeEventListener; + + unregister(); + delete globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; + jest.resetModules(); + + try { + // $FlowFixMe[cannot-write] + globalThis.addEventListener = undefined; + // $FlowFixMe[cannot-write] + globalThis.removeEventListener = undefined; + + expect(() => require('../index')).toThrow( + 'react-devtools-cdt-mcp must be imported in a browser-like environment', + ); + expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBeUndefined(); + } finally { + globalThis.addEventListener = originalAddEventListener; + globalThis.removeEventListener = originalRemoveEventListener; + } + }); + it('builds a "react" tool group exposing every facade tool', () => { expect(toolGroup.name).toBe('react'); expect(typeof toolGroup.description).toBe('string'); diff --git a/packages/react-devtools-cdt-mcp/src/index.js b/packages/react-devtools-cdt-mcp/src/index.js index c6e0887590d..fb8faa359ca 100644 --- a/packages/react-devtools-cdt-mcp/src/index.js +++ b/packages/react-devtools-cdt-mcp/src/index.js @@ -11,6 +11,19 @@ import {register} from './DevToolsCdtMcp'; // Side effect: install the facade (before React) and register the React tool // group for chrome-devtools-mcp. Import this module before React. -register(); +if ( + typeof window !== 'undefined' && + typeof window.addEventListener === 'function' && + typeof window.removeEventListener === 'function' +) { + register(); +} else { + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'react-devtools-cdt-mcp must be imported in a browser-like environment ' + + 'before React initializes. Use a client-only entry point, or the manual ' + + 'entry point for custom targets.', + ); +} export * from './DevToolsCdtMcp'; From 58a6360f8545e79a0e537bdddd34206ab1042ec3 Mon Sep 17 00:00:00 2001 From: MaxwellCohen Date: Thu, 16 Jul 2026 11:54:10 -0500 Subject: [PATCH 5/7] [Fiber] Fix false-positive hydration mismatch on `nonce` attributes (#37030) Co-authored-by: Suneil Nyamathi --- .../src/client/DOMPropertyOperations.js | 12 +++- .../__tests__/ReactDOMHydrationDiff-test.js | 69 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/packages/react-dom-bindings/src/client/DOMPropertyOperations.js b/packages/react-dom-bindings/src/client/DOMPropertyOperations.js index 2ba8ca79f5f..ee30b8034d8 100644 --- a/packages/react-dom-bindings/src/client/DOMPropertyOperations.js +++ b/packages/react-dom-bindings/src/client/DOMPropertyOperations.js @@ -42,7 +42,11 @@ export function getValueForAttribute( } return expected === undefined ? undefined : null; } - const value = node.getAttribute(name); + // When CSP is enabled, browsers hide the nonce attribute + // so we need to access the nonce property directly + // https://html.spec.whatwg.org/multipage/urls-and-fetching.html#cryptographicnonce + const isNonce = name.toLowerCase() === 'nonce'; + const value = isNonce ? (node as any).nonce : node.getAttribute(name); if (__DEV__) { checkAttributeStringCoercion(expected, name); } @@ -79,7 +83,11 @@ export function getValueForAttributeOnCustomComponent( } return expected === undefined ? undefined : null; } - const value = node.getAttribute(name); + // When CSP is enabled, browsers hide the nonce attribute + // so we need to access the nonce property directly + // https://html.spec.whatwg.org/multipage/urls-and-fetching.html#cryptographicnonce + const isNonce = name.toLowerCase() === 'nonce'; + const value = isNonce ? (node as any).nonce : node.getAttribute(name); if (value === '' && expected === true) { return true; diff --git a/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js b/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js index 835ffaac79d..40668f04529 100644 --- a/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js @@ -47,6 +47,7 @@ describe('ReactDOMServerHydration', () => { }); afterEach(() => { + jest.restoreAllMocks(); window.removeEventListener('error', errorHandler); document.body.removeChild(container); console.error = realConsoleError; @@ -525,6 +526,74 @@ describe('ReactDOMServerHydration', () => { ] `); }); + + describe('nonce', () => { + // Nonce is on HTMLOrSVGElement, so cover a few host tags that hydrate + // attributes through getValueForAttribute. + function App() { + return ( +
+