Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ReactVersions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions packages/eslint-plugin-react-hooks/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 0 additions & 2 deletions packages/eslint-plugin-react-hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions packages/eslint-plugin-react-hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ let Scheduler;
let Suspense;
let SuspenseList;
let useSyncExternalStore;
let use;
let act;
let IdleEventPriority;
let waitForAll;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
4 changes: 3 additions & 1 deletion packages/react-native-renderer/src/ReactFiberConfigFabric.js
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,9 @@ export function suspendOnActiveViewTransition(
state: SuspendedState,
container: Container,
): void {
fabricSuspendOnActiveViewTransition();
if (fabricSuspendOnActiveViewTransition != null) {
fabricSuspendOnActiveViewTransition();
}
}

export function waitForCommitToBeReady(
Expand Down
12 changes: 8 additions & 4 deletions packages/react-reconciler/src/ReactFiberBeginWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
NoFlags,
PerformedWork,
Placement,
PlacementDEV,
Hydrating,
Callback,
ContentReset,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -3862,7 +3866,7 @@ function remountFiber(
deletions.push(current);
}

newWorkInProgress.flags |= Placement;
newWorkInProgress.flags |= Placement | PlacementDEV;

// Restart work from the new fiber.
return newWorkInProgress;
Expand Down
6 changes: 4 additions & 2 deletions packages/react-reconciler/src/ReactFiberWorkLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading