Compare commits

...

6 Commits

Author SHA1 Message Date
mofeiZ
d1727fbf98 [eprh] Update changelog for 7.1.1 (#36308) 2026-04-17 12:43:35 -04:00
mofeiZ
bc249804d3 [eprh] Add back a no-op for removed component-hook-factories rule (#36307)
The `component-hook-factories` rule was removed in #35825 as part of a
feature flag cleanup, but was listed in the README as part of the manual
config example. This broke users who used a manual config (copied from
the old README) in eslint-plugin-react-hooks 7.1.0. This adds back a
deprecated no-op rule as a fix.


#35825 removed other rules (`automatic-effect-dependencies` and `fire`),
but these were for experimental features that did not ship. These were
also not referenced in the README.
2026-04-17 12:31:24 -04:00
Sebastian "Sebbie" Silbermann
da9325b519 [Fiber] Double invoke Effects in StrictMode after Fast Refresh (#35962) 2026-04-17 18:14:50 +02:00
Sebastian "Sebbie" Silbermann
67e47593b6 [Fiber] Double invoke Effects in Strict Mode during Hydration (#35961) 2026-04-17 18:09:37 +02:00
Sebastian "Sebbie" Silbermann
23fcd7cea1 Minify prod error messages for all browser bundles (#36277) 2026-04-17 18:01:56 +02:00
Zeya Peng
bf45a68dd3 Remove legacy Paper renderer shim cleanup from artifact commit workflow (#36297)
## Summary

PR #36285 deleted the Paper (legacy) renderer, including the shim file
`scripts/rollup/shims/react-native/ReactNative.js`. However, the
`runtime_commit_artifacts` workflow still tries to `rm` this file after
moving build artifacts into `compiled-rn/`. Since the file no longer
exists in the build output, `rm` (without `-f`) fails and kills the
entire step.

This has caused **every run of the Commit Artifacts workflow to fail
since #36285 landed on April 16**, blocking both `builds/facebook-www`
and `builds/facebook-fbsource` branches from receiving new build
artifacts. This in turn blocks DiffTrain from syncing React changes into
Meta's internal monorepo.
2026-04-17 10:26:22 +01:00
13 changed files with 398 additions and 23 deletions

View File

@@ -167,10 +167,6 @@ jobs:
# Delete the OSS renderers, these are sync'd to RN separately.
RENDERER_FOLDER=$BASE_FOLDER/react-native-github/Libraries/Renderer/implementations/
rm $RENDERER_FOLDER/ReactFabric-{dev,prod,profiling}.js
# Delete the legacy renderer shim, this is not sync'd and will get deleted in the future.
SHIM_FOLDER=$BASE_FOLDER/react-native-github/Libraries/Renderer/shims/
rm $SHIM_FOLDER/ReactNative.js
# Copy eslint-plugin-react-hooks
# NOTE: This is different from www, here we include the full package

View File

@@ -33,7 +33,7 @@ const canaryChannelLabel = 'canary';
const rcNumber = 0;
const stablePackages = {
'eslint-plugin-react-hooks': '7.1.0',
'eslint-plugin-react-hooks': '7.1.1',
'jest-react': '0.18.0',
react: ReactVersion,
'react-art': ReactVersion,

View File

@@ -1,3 +1,9 @@
## 7.1.1
**Note:** 7.1.0 accidentally removed the `component-hook-factories` rule, causing errors for users who referenced it in their ESLint config. This is now fixed.
- Add deprecated no-op `component-hook-factories` rule for backwards compatibility. ([@mofeiZ](https://github.com/mofeiZ) in [#36307](https://github.com/facebook/react/pull/36307))
## 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.

View File

@@ -74,7 +74,6 @@ export default [
// React Compiler rules
'react-hooks/config': 'error',
'react-hooks/error-boundaries': 'error',
'react-hooks/component-hook-factories': 'error',
'react-hooks/gating': 'error',
'react-hooks/globals': 'error',
'react-hooks/immutability': 'error',
@@ -108,7 +107,6 @@ export default [
// React Compiler rules
"react-hooks/config": "error",
"react-hooks/error-boundaries": "error",
"react-hooks/component-hook-factories": "error",
"react-hooks/gating": "error",
"react-hooks/globals": "error",
"react-hooks/immutability": "error",

View File

@@ -15,12 +15,29 @@ import {
} from './shared/ReactCompiler';
import RulesOfHooks from './rules/RulesOfHooks';
function makeDeprecatedRule(version: string): Rule.RuleModule {
return {
meta: {
type: 'suggestion',
docs: {
description: `Deprecated: this rule has been removed in ${version}.`,
},
schema: [],
deprecated: true,
},
create() {
return {};
},
};
}
const rules = {
'exhaustive-deps': ExhaustiveDeps,
'rules-of-hooks': RulesOfHooks,
...Object.fromEntries(
Object.entries(allRules).map(([name, config]) => [name, config.rule]),
),
'component-hook-factories': makeDeprecatedRule('7.1.0'),
} satisfies Record<string, Rule.RuleModule>;
const basicRuleConfigs = {

View File

@@ -20,6 +20,7 @@ let Scheduler;
let Suspense;
let SuspenseList;
let useSyncExternalStore;
let use;
let act;
let IdleEventPriority;
let waitForAll;
@@ -116,6 +117,7 @@ describe('ReactDOMServerPartialHydration', () => {
Activity = React.Activity;
Suspense = React.Suspense;
useSyncExternalStore = React.useSyncExternalStore;
use = React.use;
if (gate(flags => flags.enableSuspenseList)) {
SuspenseList = React.unstable_SuspenseList;
}
@@ -256,6 +258,77 @@ describe('ReactDOMServerPartialHydration', () => {
expect(container.textContent).toBe('HelloHello');
});
it('replays effects when a suspended boundary hydrates in StrictMode', async () => {
const log = [];
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
function EffectfulChild() {
React.useLayoutEffect(() => {
log.push('layout mount');
return () => log.push('layout unmount');
}, []);
React.useEffect(() => {
log.push('effect mount');
return () => log.push('effect unmount');
}, []);
return 'Hello';
}
function Child() {
if (suspend) {
use(promise);
}
return <EffectfulChild />;
}
function App() {
return (
<Suspense fallback="Loading...">
<Child />
</Suspense>
);
}
const element = (
<React.StrictMode>
<App />
</React.StrictMode>
);
suspend = false;
const finalHTML = ReactDOMServer.renderToString(element);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(container.textContent).toBe('Hello');
suspend = true;
ReactDOMClient.hydrateRoot(container, element);
await waitForAll([]);
expect(log).toEqual([]);
expect(container.textContent).toBe('Hello');
suspend = false;
resolve();
await promise;
await waitForAll([]);
expect(container.textContent).toBe('Hello');
if (__DEV__) {
expect(log).toEqual([
'layout mount',
'effect mount',
'layout unmount',
'effect unmount',
'layout mount',
'effect mount',
]);
} else {
expect(log).toEqual(['layout mount', 'effect mount']);
}
});
it('falls back to client rendering boundary on mismatch', async () => {
let client = false;
let suspend = false;

View File

@@ -392,6 +392,56 @@ describe('ReactDOMServerHydration', () => {
expect(element.textContent).toBe('Hi');
});
it('replays effects when hydrating a StrictMode subtree', async () => {
const log = [];
function Child() {
React.useLayoutEffect(() => {
log.push('layout mount');
return () => log.push('layout unmount');
}, []);
React.useEffect(() => {
log.push('effect mount');
return () => log.push('effect unmount');
}, []);
return <span>Hello</span>;
}
function App() {
return (
<div>
<Child />
</div>
);
}
const markup = (
<React.StrictMode>
<App />
</React.StrictMode>
);
const element = document.createElement('div');
element.innerHTML = ReactDOMServer.renderToString(markup);
expect(element.textContent).toBe('Hello');
await act(() => {
ReactDOMClient.hydrateRoot(element, markup);
});
if (__DEV__) {
expect(log).toEqual([
'layout mount',
'effect mount',
'layout unmount',
'effect unmount',
'layout mount',
'effect mount',
]);
} else {
expect(log).toEqual(['layout mount', 'effect mount']);
}
});
it('should be able to render and hydrate forwardRef components', async () => {
const FunctionComponent = ({label, forwardedRef}) => (
<div ref={forwardedRef}>{label}</div>

View File

@@ -88,6 +88,7 @@ import {
NoFlags,
PerformedWork,
Placement,
PlacementDEV,
Hydrating,
Callback,
ContentReset,
@@ -1080,7 +1081,8 @@ function updateDehydratedActivityComponent(
// Conceptually this is similar to Placement in that a new subtree is
// inserted into the React tree here. It just happens to not need DOM
// mutations because it already exists.
primaryChildFragment.flags |= Hydrating;
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
primaryChildFragment.flags |= Hydrating | PlacementDEV;
return primaryChildFragment;
}
} else {
@@ -1899,7 +1901,8 @@ function updateHostRoot(
// Conceptually this is similar to Placement in that a new subtree is
// inserted into the React tree here. It just happens to not need DOM
// mutations because it already exists.
node.flags = (node.flags & ~Placement) | Hydrating;
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
node.flags = (node.flags & ~Placement) | Hydrating | PlacementDEV;
node = node.sibling;
}
}
@@ -3104,7 +3107,8 @@ function updateDehydratedSuspenseComponent(
// Conceptually this is similar to Placement in that a new subtree is
// inserted into the React tree here. It just happens to not need DOM
// mutations because it already exists.
primaryChildFragment.flags |= Hydrating;
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
primaryChildFragment.flags |= Hydrating | PlacementDEV;
return primaryChildFragment;
}
} else {
@@ -3862,7 +3866,7 @@ function remountFiber(
deletions.push(current);
}
newWorkInProgress.flags |= Placement;
newWorkInProgress.flags |= Placement | PlacementDEV;
// Restart work from the new fiber.
return newWorkInProgress;

View File

@@ -5312,9 +5312,11 @@ function doubleInvokeEffectsInDEVIfNecessary(
if (fiber.memoizedState === null) {
// Only consider Offscreen that is visible.
// TODO (Offscreen) Handle manual mode.
if (isInStrictMode && fiber.flags & Visibility) {
// Double invoke effects on Offscreen's subtree only
if (isInStrictMode && fiber.flags & (Visibility | PlacementDEV)) {
// Double invoke effects on Offscreen's subtree
// if it is visible and its visibility has changed.
// However, we also need to consider newly hydrated Offscreen because their
// visibility flags might not have changed.
runWithFiberInDEV(fiber, doubleInvokeEffectsOnFiber, root, fiber);
} else if (fiber.subtreeFlags & PlacementDEV) {
// Something in the subtree could have been suspended.

View File

@@ -230,4 +230,127 @@ describe('Activity StrictMode', () => {
'Child mount',
]);
});
// @gate __DEV__
it('should double invoke effects on newly inserted children while Activity becomes visible', async () => {
function Parent({children}) {
log.push('Parent rendered');
React.useEffect(() => {
log.push('Parent mount');
return () => {
log.push('Parent unmount');
};
});
return <div>{children}</div>;
}
function Child({name}) {
log.push(`Child ${name} rendered`);
React.useEffect(() => {
log.push(`Child ${name} mount`);
return () => {
log.push(`Child ${name} unmount`);
};
});
return null;
}
await act(() => {
ReactNoop.render(
<React.StrictMode>
<Activity mode="hidden">
<Parent />
</Activity>
</React.StrictMode>,
);
});
expect(log).toEqual(['Parent rendered', 'Parent rendered']);
log.length = 0;
await act(() => {
ReactNoop.render(
<React.StrictMode>
<Activity mode="visible">
<Parent>
<Child name="one" />
</Parent>
</Activity>
</React.StrictMode>,
);
});
expect(log).toEqual([
'Parent rendered',
'Parent rendered',
'Child one rendered',
'Child one rendered',
'Child one mount',
'Parent mount',
// StrictMode double invocation
'Parent unmount',
'Child one unmount',
'Child one mount',
'Parent mount',
]);
log.length = 0;
await act(() => {
ReactNoop.render(
<React.StrictMode>
<Activity mode="visible">
<Parent>
<Child name="one" />
</Parent>
</Activity>
</React.StrictMode>,
);
});
expect(log).toEqual([
'Parent rendered',
'Parent rendered',
'Child one rendered',
'Child one rendered',
// single Effect invocation. No double invocation on update.
'Child one unmount',
'Parent unmount',
'Child one mount',
'Parent mount',
]);
log.length = 0;
await act(() => {
ReactNoop.render(
<React.StrictMode>
<Activity mode="visible">
<Parent>
<Child name="one" />
<Child name="two" />
</Parent>
</Activity>
</React.StrictMode>,
);
});
expect(log).toEqual([
'Parent rendered',
'Parent rendered',
'Child one rendered',
'Child one rendered',
'Child two rendered',
'Child two rendered',
// single Effect invocation for existing Components.
'Child one unmount',
'Parent unmount',
'Child one mount',
'Child two mount',
'Parent mount',
// Double Effect invocation for new Component "two"
'Child two unmount',
'Child two mount',
]);
});
});

View File

@@ -2326,6 +2326,98 @@ describe('ReactFresh', () => {
expect(finalEl.style.color).toBe('orange');
}
it('double invokes effects after a forced remount in StrictMode', async () => {
if (__DEV__) {
const log = [];
const createAppV1 = () => {
function Hello() {
React.useEffect(() => {
log.push('mount v1');
return () => log.push('unmount v1');
}, []);
return <p style={{color: 'blue'}}>Hello</p>;
}
$RefreshReg$(Hello, 'Hello');
$RefreshSig$(Hello, '1');
return Hello;
};
const App = createAppV1();
await act(() => {
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
});
expect(log).toEqual(['mount v1', 'unmount v1', 'mount v1']);
log.length = 0;
await patch(() => {
function Hello() {
React.useEffect(() => {
log.push('mount v2');
return () => log.push('unmount v2');
}, []);
return <p style={{color: 'red'}}>Hello</p>;
}
$RefreshReg$(Hello, 'Hello');
$RefreshSig$(Hello, '2');
return null;
});
expect(container.firstChild.style.color).toBe('red');
expect(log).toEqual(['unmount v1', 'mount v2', 'unmount v2', 'mount v2']);
}
});
it('double invokes an effect added during Fast Refresh remount in StrictMode', async () => {
if (__DEV__) {
const log = [];
const createAppV1 = () => {
function Hello() {
return <p style={{color: 'blue'}}>Hello</p>;
}
$RefreshReg$(Hello, 'Hello');
$RefreshSig$(Hello, '1');
return Hello;
};
const App = createAppV1();
await act(() => {
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
});
expect(log).toEqual([]);
await patch(() => {
function Hello() {
React.useEffect(() => {
log.push('mount v2');
return () => log.push('unmount v2');
}, []);
return <p style={{color: 'red'}}>Hello</p>;
}
$RefreshReg$(Hello, 'Hello');
$RefreshSig$(Hello, '2');
return null;
});
expect(container.firstChild.style.color).toBe('red');
expect(log).toEqual(['mount v2', 'unmount v2', 'mount v2']);
}
});
it('resets hooks with dependencies on hot reload', async () => {
if (__DEV__) {
let useEffectWithEmptyArrayCalls = 0;

View File

@@ -568,5 +568,19 @@
"580": "Server Function has too many bound arguments. Received %s but the limit is %s.",
"581": "BigInt is too large. Received %s digits but the limit is %s.",
"582": "Referenced Blob is not a Blob.",
"583": "The current renderer does not support view transitions. This error is likely caused by a bug in React. Please file an issue."
}
"583": "The current renderer does not support view transitions. This error is likely caused by a bug in React. Please file an issue.",
"584": "Attempted to load a Server Reference outside the hosted root.",
"585": "Invalid server action: %s",
"586": "No server callback has been registered. Call setServerCallback to register one.",
"587": "Server actions must be functions",
"588": "Could not find the module \"%s\" in the React Server Consumer Manifest. This is probably a bug in the React Server Components bundler.",
"589": "Could not find the module \"%s\" in the React Server Manifest. This is probably a bug in the React Server Components bundler.",
"590": "Cannot await or return from a thenable. You cannot await a client module from a server component.",
"591": "Cannot access %s on the server. You cannot dot into a client module from a server component. You can only pass the imported name through.",
"592": "Cannot assign to a client module from a server module.",
"593": "Attempted to call the default export of %s from the server but it's on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.",
"594": "Cannot read Symbol exports. Only named exports are supported on a client module imported on the server.",
"595": "Attempted to call %s() from the server but %s is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.",
"596": "Could not find the module \"%s\" in the React Client Manifest. This is probably a bug in the React Server Components bundler.",
"597": "The module \"%s\" is marked as an async ESM module but was loaded as a CJS proxy. This is probably a bug in the React Server Components bundler."
}

View File

@@ -415,7 +415,7 @@ const bundles = [
entry: 'react-dom/unstable_server-external-runtime',
outputPath: 'unstable_server-external-runtime.js',
global: 'ReactDOMServerExternalRuntime',
minifyWithProdErrorCodes: false,
minifyWithProdErrorCodes: true,
wrapWithModuleBoundaries: false,
externals: [],
},
@@ -454,7 +454,7 @@ const bundles = [
name: 'react-server-dom-webpack-server.browser',
condition: 'react-server',
global: 'ReactServerDOMServer',
minifyWithProdErrorCodes: false,
minifyWithProdErrorCodes: true,
wrapWithModuleBoundaries: false,
externals: ['react', 'react-dom'],
},
@@ -496,7 +496,7 @@ const bundles = [
'react-server-dom-webpack/src/client/react-flight-dom-client.browser',
name: 'react-server-dom-webpack-client.browser',
global: 'ReactServerDOMClient',
minifyWithProdErrorCodes: false,
minifyWithProdErrorCodes: true,
wrapWithModuleBoundaries: false,
externals: ['react', 'react-dom'],
},
@@ -566,7 +566,7 @@ const bundles = [
name: 'react-server-dom-turbopack-server.browser',
condition: 'react-server',
global: 'ReactServerDOMServer',
minifyWithProdErrorCodes: false,
minifyWithProdErrorCodes: true,
wrapWithModuleBoundaries: false,
externals: ['react', 'react-dom'],
},
@@ -608,7 +608,7 @@ const bundles = [
'react-server-dom-turbopack/src/client/react-flight-dom-client.browser',
name: 'react-server-dom-turbopack-client.browser',
global: 'ReactServerDOMClient',
minifyWithProdErrorCodes: false,
minifyWithProdErrorCodes: true,
wrapWithModuleBoundaries: false,
externals: ['react', 'react-dom'],
},
@@ -641,7 +641,7 @@ const bundles = [
name: 'react-server-dom-parcel-server.browser',
condition: 'react-server',
global: 'ReactServerDOMServer',
minifyWithProdErrorCodes: false,
minifyWithProdErrorCodes: true,
wrapWithModuleBoundaries: false,
externals: ['react', 'react-dom'],
},
@@ -682,7 +682,7 @@ const bundles = [
entry: 'react-server-dom-parcel/src/client/react-flight-dom-client.browser',
name: 'react-server-dom-parcel-client.browser',
global: 'ReactServerDOMClient',
minifyWithProdErrorCodes: false,
minifyWithProdErrorCodes: true,
wrapWithModuleBoundaries: false,
externals: ['react', 'react-dom'],
},
@@ -731,7 +731,7 @@ const bundles = [
bundleTypes: [NODE_DEV, NODE_PROD, ESM_DEV, ESM_PROD],
moduleType: RENDERER,
entry: 'react-server-dom-esm/client.browser',
minifyWithProdErrorCodes: false,
minifyWithProdErrorCodes: true,
wrapWithModuleBoundaries: false,
externals: ['react', 'react-dom'],
},