diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
index 3e6af7bc974..2572d7043ca 100644
--- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
@@ -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 ;
+ }
+
+ function App() {
+ return (
+
+
+
+ );
+ }
+
+ const element = (
+
+
+
+ );
+
+ 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;
diff --git a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js
index 5675c7eb0e7..ccfda03e1bc 100644
--- a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js
+++ b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js
@@ -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 Hello;
+ }
+
+ function App() {
+ return (
+
+
+
+ );
+ }
+
+ const markup = (
+
+
+
+ );
+
+ 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}) => (
{label}
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js
index 49a4c53c894..5f99ed0f6be 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.js
@@ -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 {
@@ -1896,7 +1898,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;
}
}
@@ -3101,7 +3104,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 {
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js
index d055b271ad7..8ee8736356f 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js
@@ -5255,9 +5255,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.
diff --git a/packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js b/packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js
index fa67df3b8a1..887ac7d1ce8 100644
--- a/packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js
+++ b/packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js
@@ -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 {children}
;
+ }
+
+ 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(
+
+
+
+
+ ,
+ );
+ });
+
+ expect(log).toEqual(['Parent rendered', 'Parent rendered']);
+
+ log.length = 0;
+ await act(() => {
+ ReactNoop.render(
+
+
+
+
+
+
+ ,
+ );
+ });
+
+ 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(
+
+
+
+
+
+
+ ,
+ );
+ });
+
+ 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(
+
+
+
+
+
+
+
+ ,
+ );
+ });
+
+ 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',
+ ]);
+ });
});