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/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)) 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-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 cbe70a0d88f8..89aa1ee4c840 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js +++ b/packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js @@ -16,9 +16,13 @@ import type { GestureTimeline, } from './ReactFiberConfigFabric'; +import {allocateTag} from './ReactFiberConfigFabric'; + const { applyViewTransitionName: fabricApplyViewTransitionName, + createViewTransitionInstance: fabricCreateViewTransitionInstance, startViewTransition: fabricStartViewTransition, + startViewTransitionReadyFinished: fabricStartViewTransitionReadyFinished, } = nativeFabricUIManager; export type InstanceMeasurement = { @@ -196,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), @@ -251,6 +257,7 @@ export function startViewTransition( transition.ready.then(() => { spawnedWorkCallback(); + fabricStartViewTransitionReadyFinished(); }); transition.finished.finally(() => { diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 0b115199fc3b..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,6 +4961,12 @@ function finishedTask( hoistHoistables(boundaryRow.hoistables, boundary.contentState); } if (!isEligibleForOutlining(request, boundary)) { + // 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(); if (boundaryRow !== null) { @@ -4963,6 +4975,7 @@ function finishedTask( finishSuspenseListRow(request, boundaryRow); } } + request.allPendingTasks--; } if ( @@ -4988,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 { diff --git a/scripts/flow/react-native-host-hooks.js b/scripts/flow/react-native-host-hooks.js index 7fd039cb01e5..c7e83368dd68 100644 --- a/scripts/flow/react-native-host-hooks.js +++ b/scripts/flow/react-native-host-hooks.js @@ -213,9 +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, ... };