From 3c397fe76015d6988cdcb6f74aff978a3cc6a37a Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Wed, 26 Aug 2026 12:49:48 +0200 Subject: [PATCH 1/2] [test] Stop leaking mocked performance clocks into later test files (#37379) Test files running in the Node.js Jest environment share the worker process's `performance` object, because [`jest-environment-node` installs it by reference](https://github.com/jestjs/jest/blob/v29.7.0/packages/jest-environment-node/src/index.ts#L85-L103) rather than by copy. When a test mocked the clock with `Object.defineProperty(performance, 'now', ...)`, the mutation hit the shared object and was never undone, since Jest only restores `jest.spyOn` mocks when a file's runtime is torn down ([`jest-runtime`'s `teardown()` calls `restoreAllMocks()`](https://github.com/jestjs/jest/blob/v29.7.0/packages/jest-runtime/src/index.ts#L1358-L1359)). Every subsequent test file in the same worker then observed the fake clock, including jsdom-based files, whose [`performance.now()` subtracts a window-creation timestamp from the shared object's `now()`](https://github.com/jsdom/jsdom/blob/v22.1.0/lib/jsdom/living/hr-time/Performance-impl.js#L13-L14). This change switches the six affected test files to `jest.spyOn(performance, 'now')` and `jest.spyOn(performance, 'timeOrigin', 'get')`, which Jest restores automatically at teardown. `ReactFlightDOMEdge-test.js` runs in jsdom and therefore did not leak, but it used the same pattern and is converted for consistency. This change is mostly for test hygiene. Was discovered while investigating a flaky `{"time":NaN}` serialisation bug (e.g. https://github.com/react/react/actions/runs/32878999825/job/97903912119) Co-authored-by: Claude Code (kimi-k3[1m]) --- .../react-client/src/__tests__/ReactFlight-test.js | 11 ++--------- .../src/__tests__/ReactFlightDebugChannel-test.js | 11 ++--------- .../src/__tests__/ReactOwnerStacks-test.js | 11 ++--------- .../src/__tests__/ReactPerformanceTrack-test.js | 9 ++++----- .../src/__tests__/ReactFlightDOMEdge-test.js | 11 ++--------- .../src/__tests__/ReactFlightServer-test.js | 11 ++--------- 6 files changed, 14 insertions(+), 50 deletions(-) diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 5bb9efea932b..5347c57425f8 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -92,17 +92,10 @@ describe('ReactFlight', () => { beforeEach(() => { // Mock performance.now for timing tests let time = 10; - const now = jest.fn().mockImplementation(() => { + jest.spyOn(performance, 'timeOrigin', 'get').mockReturnValue(time); + jest.spyOn(performance, 'now').mockImplementation(() => { return time++; }); - Object.defineProperty(performance, 'timeOrigin', { - value: time, - configurable: true, - }); - Object.defineProperty(performance, 'now', { - value: now, - configurable: true, - }); jest.resetModules(); jest.mock('react', () => require('react/react.react-server')); diff --git a/packages/react-client/src/__tests__/ReactFlightDebugChannel-test.js b/packages/react-client/src/__tests__/ReactFlightDebugChannel-test.js index 6d6916926147..58e68a7d865d 100644 --- a/packages/react-client/src/__tests__/ReactFlightDebugChannel-test.js +++ b/packages/react-client/src/__tests__/ReactFlightDebugChannel-test.js @@ -29,17 +29,10 @@ describe('ReactFlight', () => { beforeEach(() => { // Mock performance.now for timing tests let time = 10; - const now = jest.fn().mockImplementation(() => { + jest.spyOn(performance, 'timeOrigin', 'get').mockReturnValue(time); + jest.spyOn(performance, 'now').mockImplementation(() => { return time++; }); - Object.defineProperty(performance, 'timeOrigin', { - value: time, - configurable: true, - }); - Object.defineProperty(performance, 'now', { - value: now, - configurable: true, - }); jest.resetModules(); jest.mock('react', () => require('react/react.react-server')); diff --git a/packages/react-reconciler/src/__tests__/ReactOwnerStacks-test.js b/packages/react-reconciler/src/__tests__/ReactOwnerStacks-test.js index e7b0581d5f4a..4dc2f4ef0f22 100644 --- a/packages/react-reconciler/src/__tests__/ReactOwnerStacks-test.js +++ b/packages/react-reconciler/src/__tests__/ReactOwnerStacks-test.js @@ -27,17 +27,10 @@ describe('ReactOwnerStacks', () => { time += timeMS; }; - const now = jest.fn().mockImplementation(() => { + jest.spyOn(performance, 'timeOrigin', 'get').mockReturnValue(time); + jest.spyOn(performance, 'now').mockImplementation(() => { return time++; }); - Object.defineProperty(performance, 'timeOrigin', { - value: time, - configurable: true, - }); - Object.defineProperty(performance, 'now', { - value: now, - configurable: true, - }); jest.resetModules(); React = require('react'); diff --git a/packages/react-reconciler/src/__tests__/ReactPerformanceTrack-test.js b/packages/react-reconciler/src/__tests__/ReactPerformanceTrack-test.js index cdc4f40f4a2b..77a8d66fb3d9 100644 --- a/packages/react-reconciler/src/__tests__/ReactPerformanceTrack-test.js +++ b/packages/react-reconciler/src/__tests__/ReactPerformanceTrack-test.js @@ -19,8 +19,9 @@ describe('ReactPerformanceTracks', () => { beforeEach(() => { performanceMeasureCalls.length = 0; - Object.defineProperty(performance, 'measure', { - value: jest.fn((measureName, reusableOptions) => { + jest + .spyOn(performance, 'measure') + .mockImplementation((measureName, reusableOptions) => { performanceMeasureCalls.push([ measureName, { @@ -28,9 +29,7 @@ describe('ReactPerformanceTracks', () => { ...reusableOptions, }, ]); - }), - configurable: true, - }); + }); console.timeStamp = () => {}; jest.spyOn(console, 'timeStamp').mockImplementation(() => {}); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index 2e846579db36..39aba780e453 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -47,17 +47,10 @@ describe('ReactFlightDOMEdge', () => { beforeEach(() => { // Mock performance.now for timing tests let time = 10; - const now = jest.fn().mockImplementation(() => { + jest.spyOn(performance, 'timeOrigin', 'get').mockReturnValue(time); + jest.spyOn(performance, 'now').mockImplementation(() => { return time++; }); - Object.defineProperty(performance, 'timeOrigin', { - value: time, - configurable: true, - }); - Object.defineProperty(performance, 'now', { - value: now, - configurable: true, - }); jest.resetModules(); diff --git a/packages/react-server/src/__tests__/ReactFlightServer-test.js b/packages/react-server/src/__tests__/ReactFlightServer-test.js index 17480985db5b..b17185b6ef1f 100644 --- a/packages/react-server/src/__tests__/ReactFlightServer-test.js +++ b/packages/react-server/src/__tests__/ReactFlightServer-test.js @@ -46,17 +46,10 @@ describe('ReactFlight', () => { time += timeMS; jest.advanceTimersByTime(timeMS); }; - const now = jest.fn().mockImplementation(() => { + jest.spyOn(performance, 'timeOrigin', 'get').mockReturnValue(time); + jest.spyOn(performance, 'now').mockImplementation(() => { return time++; }); - Object.defineProperty(performance, 'timeOrigin', { - value: time, - configurable: true, - }); - Object.defineProperty(performance, 'now', { - value: now, - configurable: true, - }); jest.resetModules(); jest.mock('react', () => require('react/react.react-server')); From a1124489a5e8f81e16ac957699a60038b965f502 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 26 Aug 2026 19:09:31 +0800 Subject: [PATCH 2/2] [DOM] Treat omitted Fragment Event listener options same as `capture: false` (#37251) `FragmentInstance` tracks its event listeners so they can be applied to children added later, and matches them by a normalized options identity. Omitted options currently normalize to a different identity than an explicit `false` or `{capture: false}`, even though both mean `capture: false` per the `EventTarget` contract, where listener identity is the tuple of type, callback, and capture flag. As a result, a listener added without an options argument cannot be removed with an explicit capture-false value (or the reverse). This change normalizes omitted options to the same capture-false identity as `false` and `{capture: false}`. The first commit adds a test to the FragmentRef suite characterizing the current behavior; the second commit contains the fix and the updated assertions. --------- Co-authored-by: Claude Code (kimi-k3[1m]) --- .../src/client/ReactFiberConfigDOM.js | 2 +- .../__tests__/ReactDOMFragmentRefs-test.js | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 00e8e6ad4f34..00d082fecff5 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -3152,7 +3152,7 @@ function normalizeListenerOptions( opts: ?EventListenerOptionsOrUseCapture, ): string { if (opts == null) { - return '0'; + return 'c=0'; } if (typeof opts === 'boolean') { diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js index 60c86301e4a6..3611e973cb97 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js @@ -832,6 +832,47 @@ describe('FragmentRefs', () => { expect(logs).toEqual([]); }); + // @gate enableFragmentRefs + it('matches listeners by their normalized capture flag', async () => { + const fragmentRef = React.createRef(); + const childRef = React.createRef(); + const root = ReactDOMClient.createRoot(container); + const logs = []; + + function addedWithOmittedOptions() { + logs.push('addedWithOmittedOptions'); + } + + function addedWithCaptureFalse() { + logs.push('addedWithCaptureFalse'); + } + + await act(() => { + root.render( + +
child
+
, + ); + }); + + fragmentRef.current.addEventListener('click', addedWithOmittedOptions); + fragmentRef.current.addEventListener('click', addedWithCaptureFalse, { + capture: false, + }); + + // Omitted options and an explicit capture: false are the same + // EventTarget listener identity, so each removal should match. + fragmentRef.current.removeEventListener( + 'click', + addedWithOmittedOptions, + false, + ); + fragmentRef.current.removeEventListener('click', addedWithCaptureFalse); + + childRef.current.click(); + expect(logs).toEqual([]); + }); + // @gate enableFragmentRefs it('adds and removes event listeners from children with multiple fragments', async () => { const fragmentRef = React.createRef();