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', + ]); + }); });