From ea6792026ff7bc4c9c663fd09149cc523490cb1a Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 16 Apr 2026 13:26:34 -0700 Subject: [PATCH 1/5] [Fizz] prevent reentrant finishedTask from calling completeAll multiple times (#36287) It is possible for the fallback tasks from a Suspense boundary to trigger an early `completeAll` call which is later repeated due to `finishedTask` reentrancy. For node.js in particular this might be problematic since we invoke a callback on each `completeAll` call but in general it just isn't the right semantics since the call is running slightly earlier than the completion of the last `finishedTask` invocation. This change ensures that any reentrant `finishedTask` calls (due to soft aborting fallback tasks) omit the `completeAll` call by temporarily incrementing the total pending tasks. --- .gitignore | 2 +- .../src/__tests__/ReactDOMFizzServer-test.js | 94 +++++++++++++++++++ packages/react-server/src/ReactFizzServer.js | 6 ++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 438d125b47a0..16982164c421 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ chrome-user-data .idea *.iml .vscode +.zed *.swp *.swo /tmp @@ -40,4 +41,3 @@ packages/react-devtools-fusebox/dist packages/react-devtools-inline/dist packages/react-devtools-shell/dist packages/react-devtools-timeline/dist - diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 6ff107786f3b..efb7e887cf18 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -6289,6 +6289,100 @@ describe('ReactDOMFizzServer', () => { expect(getVisibleChildren(container)).toEqual('Hi'); }); + // Regression: finishedTask aborting remaining fallback tasks from a + // completed boundary could reenter itself via abortTaskSoft and fire + // onAllReady twice (the inner call drained allPendingTasks to 0 and + // called completeAll, then the outer call re-observed the same 0). + it('only fires onAllReady once when a boundary with an instrumented sync-resolving thenable completes', async () => { + // Mirrors Flight-client chunk behavior: the status-probe .then() in + // trackUsedThenable stays pending, but the ping-attaching .then() in + // renderNode's catch resolves synchronously. This reorders the work + // queue so the fallback task is still in fallbackAbortableTasks when + // the content task completes. + function createDeferredSyncThenable(value) { + let thenCallCount = 0; + return { + status: 'pending', + value: undefined, + then(resolve) { + thenCallCount++; + if (thenCallCount > 1) { + this.status = 'fulfilled'; + this.value = value; + resolve(value); + } + }, + }; + } + + const thenable = createDeferredSyncThenable('hello'); + function AsyncContent() { + return ; + } + + let allReadyCount = 0; + await act(() => { + const {pipe} = renderToPipeableStream( + }> + + , + { + onAllReady() { + allReadyCount++; + }, + }, + ); + pipe(writable); + }); + + expect(allReadyCount).toBe(1); + expect(getVisibleChildren(container)).toEqual('hello'); + }); + + // Same bug, hit without any sync-thenable trickery: if the fallback + // also suspends, its spawned sub-task lives in fallbackAbortableTasks + // and can still be there when the content task completes first. + it('only fires onAllReady once when both content and fallback suspend on real promises', async () => { + let resolveContent; + const contentPromise = new Promise(r => (resolveContent = r)); + // The fallback promise never resolves — the fallback-sub-task gets + // soft-aborted when the content completes, so we never need it. + const fallbackPromise = new Promise(() => {}); + + function AsyncContent() { + return ; + } + function AsyncFallback() { + return ; + } + + let allReadyCount = 0; + await act(() => { + const {pipe} = renderToPipeableStream( + }> + + , + { + onAllReady() { + allReadyCount++; + }, + }, + ); + pipe(writable); + }); + + // Resolving content alone is enough: the fallback-sub-task is still + // in fallbackAbortableTasks when the content task completes, and + // abortTaskSoft on it reenters finishedTask. + await act(async () => { + resolveContent('hello'); + await contentPromise; + }); + + expect(allReadyCount).toBe(1); + expect(getVisibleChildren(container)).toEqual('hello'); + }); + it('promise as node', async () => { const promise = Promise.resolve('Hi'); await act(async () => { diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 0b115199fc3b..c1fc1531d3d9 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -4955,8 +4955,14 @@ function finishedTask( hoistHoistables(boundaryRow.hoistables, boundary.contentState); } if (!isEligibleForOutlining(request, boundary)) { + // abortTaskSoft reenters finishedTask for each aborted task, which + // decrements allPendingTasks. Ensure that these reentrant finsihedTask + // calls do not call `completeAll` too early by forcing the task counter + // above zero for their duration. + request.allPendingTasks++; boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request); boundary.fallbackAbortableTasks.clear(); + request.allPendingTasks--; if (boundaryRow !== null) { // If we aren't eligible for outlining, we don't have to wait until we flush it. if (--boundaryRow.pendingTasks === 0) { From fe5160140d79ea023a9db6fbb914ed6c6b6ae0dd Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Thu, 16 Apr 2026 16:41:05 -0400 Subject: [PATCH 2/5] Wire up startViewTransitionReadyFinished in Fabric (#36246) ## Summary - Imports `startViewTransitionReadyFinished` from `nativeFabricUIManager` in `ReactFiberConfigFabricWithViewTransition` - Calls `fabricStartViewTransitionReadyFinished()` when the view transition `ready` promise resolves This is not a config function, but it's helpful to have it notify fabric ViewTransition runtime when ready callback is done. Right now we're testing animation kicked off from view transition event handlers, this is signal to know when animations that belong to a transition have all started. ## Test plan - Existing Fabric renderer tests should continue to pass - View transition ready callback now notifies the native module when finished --- .../src/ReactFiberConfigFabricWithViewTransition.js | 2 ++ scripts/flow/react-native-host-hooks.js | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js b/packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js index cbe70a0d88f8..fa0b40e5ca48 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js +++ b/packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js @@ -19,6 +19,7 @@ import type { const { applyViewTransitionName: fabricApplyViewTransitionName, startViewTransition: fabricStartViewTransition, + startViewTransitionReadyFinished: fabricStartViewTransitionReadyFinished, } = nativeFabricUIManager; export type InstanceMeasurement = { @@ -251,6 +252,7 @@ export function startViewTransition( transition.ready.then(() => { spawnedWorkCallback(); + fabricStartViewTransitionReadyFinished(); }); transition.finished.finally(() => { diff --git a/scripts/flow/react-native-host-hooks.js b/scripts/flow/react-native-host-hooks.js index 7fd039cb01e5..e8e394f618a0 100644 --- a/scripts/flow/react-native-host-hooks.js +++ b/scripts/flow/react-native-host-hooks.js @@ -217,5 +217,6 @@ declare const nativeFabricUIManager: { finished: Promise, ready: Promise, }, + startViewTransitionReadyFinished: () => void, ... }; From f6fe4275c7ec869e20a4b91f70df88cdc14d5161 Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Thu, 16 Apr 2026 16:59:54 -0400 Subject: [PATCH 3/5] Wire up createViewTransitionInstance and suspendOnActiveViewTransition in Fabric (#36196) ## Summary - Wires up the native `fabricCreateViewTransitionInstance` call in `createViewTransitionInstance` which will create a ShadowNode for old pseudo element - Extracts tag allocation logic into a shared `allocateTag()` function exported from `ReactFiberConfigFabric` - Imports `allocateTag` in `ReactFiberConfigFabricWithViewTransition` - Reuses `allocateTag()` in `createInstance` and `createTextInstance` instead of inline tag incrementing - Wires up native `fabricSuspendOnActiveViewTransition` call in `suspendOnActiveViewTransition` which suspends another view transition when the previous one is not yet finished ## Test plan - Existing Fabric renderer tests should continue to pass - ViewTransition instance creation now properly allocates a tag and calls the native module --- .../src/ReactFiberConfigFabric.js | 16 +++++++++++----- .../ReactFiberConfigFabricWithViewTransition.js | 5 +++++ scripts/flow/react-native-host-hooks.js | 2 ++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/react-native-renderer/src/ReactFiberConfigFabric.js b/packages/react-native-renderer/src/ReactFiberConfigFabric.js index 7b603201cc5d..fe3536c2050b 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigFabric.js +++ b/packages/react-native-renderer/src/ReactFiberConfigFabric.js @@ -60,6 +60,7 @@ const { unstable_ContinuousEventPriority: FabricContinuousPriority, unstable_IdleEventPriority: FabricIdlePriority, unstable_getCurrentEventPriority: fabricGetCurrentEventPriority, + suspendOnActiveViewTransition: fabricSuspendOnActiveViewTransition, } = nativeFabricUIManager; import {getClosestInstanceFromNode} from './ReactFabricComponentTree'; @@ -88,6 +89,11 @@ const {get: getViewConfigForType} = ReactNativeViewConfigRegistry; // % 2 === 0 means it is a Fabric tag. // This means that they never overlap. let nextReactTag = 2; +export function allocateTag(): number { + const tag = nextReactTag; + nextReactTag += 2; + return tag; +} type InternalInstanceHandle = Object; @@ -180,8 +186,7 @@ export function createInstance( hostContext: HostContext, internalInstanceHandle: InternalInstanceHandle, ): Instance { - const tag = nextReactTag; - nextReactTag += 2; + const tag = allocateTag(); const viewConfig = getViewConfigForType(type); @@ -231,8 +236,7 @@ export function createTextInstance( } } - const tag = nextReactTag; - nextReactTag += 2; + const tag = allocateTag(); const node = createNode( tag, // reactTag @@ -652,7 +656,9 @@ export function suspendInstance( export function suspendOnActiveViewTransition( state: SuspendedState, container: Container, -): void {} +): void { + fabricSuspendOnActiveViewTransition(); +} export function waitForCommitToBeReady( state: SuspendedState, diff --git a/packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js b/packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js index fa0b40e5ca48..89aa1ee4c840 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js +++ b/packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js @@ -16,8 +16,11 @@ import type { GestureTimeline, } from './ReactFiberConfigFabric'; +import {allocateTag} from './ReactFiberConfigFabric'; + const { applyViewTransitionName: fabricApplyViewTransitionName, + createViewTransitionInstance: fabricCreateViewTransitionInstance, startViewTransition: fabricStartViewTransition, startViewTransitionReadyFinished: fabricStartViewTransitionReadyFinished, } = nativeFabricUIManager; @@ -197,6 +200,8 @@ export function addViewTransitionFinishedListener( export function createViewTransitionInstance( name: string, ): ViewTransitionInstance { + const tag = allocateTag(); + fabricCreateViewTransitionInstance(name, tag); return { name, old: new (ViewTransitionPseudoElement: any)('old', name), diff --git a/scripts/flow/react-native-host-hooks.js b/scripts/flow/react-native-host-hooks.js index e8e394f618a0..c7e83368dd68 100644 --- a/scripts/flow/react-native-host-hooks.js +++ b/scripts/flow/react-native-host-hooks.js @@ -213,10 +213,12 @@ declare const nativeFabricUIManager: { name: string, className: ?string, ) => void, + createViewTransitionInstance: (name: string, tag: number) => void, startViewTransition: (mutationCallback: () => void) => { finished: Promise, ready: Promise, }, startViewTransitionReadyFinished: () => void, + suspendOnActiveViewTransition: () => void, ... }; From 4b073f4887f48aabf040ac56b980160b801c8099 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 16 Apr 2026 14:15:06 -0700 Subject: [PATCH 4/5] [Fizz] add additional task reentrancy protections (#36291) The prior fix for finishedTask reentrancy solved an observed failure. This change adds a bit of defensive bookeeping to protect against other theoretical reentrant task finishing that might fail in simlar ways but where we don't have a clear demonstration of the bug. --- packages/react-server/src/ReactFizzServer.js | 23 +++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index c1fc1531d3d9..73b3e435d189 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -4438,9 +4438,15 @@ function erroredTask( const boundaryRow = boundary.row; if (boundaryRow !== null) { // Unblock the SuspenseListRow that was blocked by this boundary. + // finishSuspenseListRow → unblockSuspenseListRow → finishedTask reenters + // and decrements allPendingTasks. Pin the counter above zero so those + // nested calls can't trip completeAll before this outer frame's own + // zero check at the end. + request.allPendingTasks++; if (--boundaryRow.pendingTasks === 0) { finishSuspenseListRow(request, boundaryRow); } + request.allPendingTasks--; } // Regardless of what happens next, this boundary won't be displayed, @@ -4955,20 +4961,21 @@ function finishedTask( hoistHoistables(boundaryRow.hoistables, boundary.contentState); } if (!isEligibleForOutlining(request, boundary)) { - // abortTaskSoft reenters finishedTask for each aborted task, which - // decrements allPendingTasks. Ensure that these reentrant finsihedTask - // calls do not call `completeAll` too early by forcing the task counter - // above zero for their duration. + // abortTaskSoft (below) and finishSuspenseListRow → unblockSuspenseListRow + // → finishedTask (further below) both reenter finishedTask and decrement + // allPendingTasks. Pin the counter above zero for the duration of these + // fan-outs so a nested finishedTask can't observe 0 and call completeAll + // before this outer call reaches its own zero check. request.allPendingTasks++; boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request); boundary.fallbackAbortableTasks.clear(); - request.allPendingTasks--; if (boundaryRow !== null) { // If we aren't eligible for outlining, we don't have to wait until we flush it. if (--boundaryRow.pendingTasks === 0) { finishSuspenseListRow(request, boundaryRow); } } + request.allPendingTasks--; } if ( @@ -4994,11 +5001,17 @@ function finishedTask( boundaryRow.next, ); } + // finishSuspenseListRow → unblockSuspenseListRow → finishedTask reenters + // and decrements allPendingTasks. Pin the counter above zero so those + // nested calls can't trip completeAll before this outer frame's own + // zero check at the end. + request.allPendingTasks++; if (--boundaryRow.pendingTasks === 0) { // This is really unnecessary since we've already postponed the boundaries but // for pairity with other track+finish paths. We might end up using the hoisting. finishSuspenseListRow(request, boundaryRow); } + request.allPendingTasks--; } } } else { From 77319e2af0f9adf4269fc209879c351a8d71da21 Mon Sep 17 00:00:00 2001 From: mofeiZ <34200447+mofeiZ@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:03:13 -0400 Subject: [PATCH 5/5] [eprh] Update changelog for 7.1.0 (#36292) --- packages/eslint-plugin-react-hooks/CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/eslint-plugin-react-hooks/CHANGELOG.md b/packages/eslint-plugin-react-hooks/CHANGELOG.md index 9f88047193e7..4ad4bb411c7e 100644 --- a/packages/eslint-plugin-react-hooks/CHANGELOG.md +++ b/packages/eslint-plugin-react-hooks/CHANGELOG.md @@ -1,3 +1,16 @@ +## 7.1.0 + +This release adds ESLint v10 support, improves performance by skipping compilation for non-React files, and includes compiler lint improvements including better `set-state-in-effect` detection, improved ref validation, and more helpful error reporting. + +- Add ESLint v10 support. ([@nicolo-ribaudo](https://github.com/nicolo-ribaudo) in [#35720](https://github.com/facebook/react/pull/35720)) +- Skip compilation for non-React files to improve performance. ([@josephsavona](https://github.com/josephsavona) in [#35589](https://github.com/facebook/react/pull/35589)) +- Fix exhaustive deps bug with Flow type casting. ([@jorge-cab](https://github.com/jorge-cab) in [#35691](https://github.com/facebook/react/pull/35691)) +- Fix `useEffectEvent` checks in component syntax. ([@jbrown215](https://github.com/jbrown215) in [#35041](https://github.com/facebook/react/pull/35041)) +- Improved `set-state-in-effect` validation with fewer false negatives. ([@jorge-cab](https://github.com/jorge-cab) in [#35134](https://github.com/facebook/react/pull/35134), [@josephsavona](https://github.com/josephsavona) in [#35147](https://github.com/facebook/react/pull/35147), [@jackpope](https://github.com/jackpope) in [#35214](https://github.com/facebook/react/pull/35214), [@chesnokov-tony](https://github.com/chesnokov-tony) in [#35419](https://github.com/facebook/react/pull/35419), [@jsleitor](https://github.com/jsleitor) in [#36107](https://github.com/facebook/react/pull/36107)) +- Improved ref validation for non-mutating functions and event handler props. ([@josephsavona](https://github.com/josephsavona) in [#35893](https://github.com/facebook/react/pull/35893), [@kolvian](https://github.com/kolvian) in [#35062](https://github.com/facebook/react/pull/35062)) +- Compiler now reports all errors instead of stopping at the first. ([@josephsavona](https://github.com/josephsavona) in [#35873](https://github.com/facebook/react/pull/35873)–[#35884](https://github.com/facebook/react/pull/35884)) +- Improved source locations and error display in compiler diagnostics. ([@nathanmarks](https://github.com/nathanmarks) in [#35348](https://github.com/facebook/react/pull/35348), [@josephsavona](https://github.com/josephsavona) in [#34963](https://github.com/facebook/react/pull/34963)) + ## 7.0.1 - Disallowed passing inline `useEffectEvent` values as JSX props to guard against accidental propagation. ([#34820](https://github.com/facebook/react/pull/34820) by [@jf-eirinha](https://github.com/jf-eirinha))