diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 773ab3232a44..a55b8774c0ac 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -853,6 +853,75 @@ describe('ReactDOMServerPartialHydration', () => { expect(span.className).toBe('hi'); }); + // @gate experimental + it('warns but works if setState is called before commit in a dehydrated component', async () => { + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + let updateText; + + function Child() { + const [state, setState] = React.useState('Hello'); + updateText = setState; + Scheduler.unstable_yieldValue('Child'); + if (suspend) { + throw promise; + } else { + return state; + } + } + + function Sibling() { + Scheduler.unstable_yieldValue('Sibling'); + return null; + } + + function App() { + return ( +
+ + + + +
+ ); + } + + suspend = false; + const finalHTML = ReactDOMServer.renderToString(); + expect(Scheduler).toHaveYielded(['Child', 'Sibling']); + + const container = document.createElement('div'); + container.innerHTML = finalHTML; + + const root = ReactDOM.createRoot(container, {hydrate: true}); + + await act(async () => { + suspend = true; + root.render(); + expect(Scheduler).toFlushAndYieldThrough(['Child']); + + // While we're part way through the hydration, we update the state. + // This will schedule an update on the children of the suspense boundary. + expect(() => updateText('Hi')).toErrorDev( + "Can't perform a React state update on a component that hasn't mounted yet.", + ); + + // This will throw it away and rerender. + expect(Scheduler).toFlushAndYield(['Child', 'Sibling']); + + expect(container.textContent).toBe('Hello'); + + suspend = false; + resolve(); + await promise; + }); + expect(Scheduler).toHaveYielded(['Child', 'Sibling']); + + expect(container.textContent).toBe('Hello'); + }); + // @gate experimental it('blocks the update to hydrate first if context has changed', async () => { let suspend = false; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 3ac10be460a9..e2ff1032b828 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -76,6 +76,7 @@ import { } from './ReactTypeOfMode'; import { HostRoot, + IndeterminateComponent, ClassComponent, SuspenseComponent, SuspenseListComponent, @@ -500,6 +501,14 @@ function markUpdateLaneFromFiberToRoot( if (alternate !== null) { alternate.lanes = mergeLanes(alternate.lanes, lane); } + if (__DEV__) { + if ( + alternate === null && + (fiber.effectTag & (Placement | Hydrating)) !== NoEffect + ) { + warnAboutUpdateOnNotYetMountedFiberInDEV(fiber); + } + } // Walk the parent path to the root and update the child expiration time. let node = fiber.return; let root = null; @@ -508,6 +517,14 @@ function markUpdateLaneFromFiberToRoot( } else { while (node !== null) { alternate = node.alternate; + if (__DEV__) { + if ( + alternate === null && + (node.effectTag & (Placement | Hydrating)) !== NoEffect + ) { + warnAboutUpdateOnNotYetMountedFiberInDEV(fiber); + } + } node.childLanes = mergeLanes(node.childLanes, lane); if (alternate !== null) { alternate.childLanes = mergeLanes(alternate.childLanes, lane); @@ -2710,6 +2727,60 @@ function flushRenderPhaseStrictModeWarningsInDEV() { } } +let didWarnStateUpdateForNotYetMountedComponent: Set | null = null; +function warnAboutUpdateOnNotYetMountedFiberInDEV(fiber) { + if (__DEV__) { + if ((executionContext & RenderContext) !== NoContext) { + // We let the other warning about render phase updates deal with this one. + return; + } + + const tag = fiber.tag; + if ( + tag !== IndeterminateComponent && + tag !== HostRoot && + tag !== ClassComponent && + tag !== FunctionComponent && + tag !== ForwardRef && + tag !== MemoComponent && + tag !== SimpleMemoComponent && + tag !== Block + ) { + // Only warn for user-defined components, not internal ones like Suspense. + return; + } + + // We show the whole stack but dedupe on the top component's name because + // the problematic code almost always lies inside that component. + const componentName = getComponentName(fiber.type) || 'ReactComponent'; + if (didWarnStateUpdateForNotYetMountedComponent !== null) { + if (didWarnStateUpdateForNotYetMountedComponent.has(componentName)) { + return; + } + didWarnStateUpdateForNotYetMountedComponent.add(componentName); + } else { + didWarnStateUpdateForNotYetMountedComponent = new Set([componentName]); + } + + const previousFiber = ReactCurrentFiberCurrent; + try { + setCurrentDebugFiberInDEV(fiber); + console.error( + "Can't perform a React state update on a component that hasn't mounted yet. " + + 'This indicates that you have a side-effect in your render function that ' + + 'asynchronously later calls tries to update the component. Move this work to ' + + 'useEffect instead.', + ); + } finally { + if (previousFiber) { + setCurrentDebugFiberInDEV(fiber); + } else { + resetCurrentDebugFiberInDEV(); + } + } + } +} + let didWarnStateUpdateForUnmountedComponent: Set | null = null; function warnAboutUpdateOnUnmountedFiberInDEV(fiber) { if (__DEV__) { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 2932e946017a..e76d0bf38b4f 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -96,6 +96,7 @@ import { } from './ReactTypeOfMode'; import { HostRoot, + IndeterminateComponent, ClassComponent, SuspenseComponent, SuspenseListComponent, @@ -498,6 +499,15 @@ function markUpdateTimeFromFiberToRoot(fiber, expirationTime) { if (alternate !== null && alternate.expirationTime < expirationTime) { alternate.expirationTime = expirationTime; } + if (__DEV__) { + if ( + alternate === null && + (fiber.effectTag & (Placement | Hydrating)) !== NoEffect + ) { + warnAboutUpdateOnNotYetMountedFiberInDEV(fiber); + } + } + // Walk the parent path to the root and update the child expiration time. let node = fiber.return; let root = null; @@ -506,6 +516,14 @@ function markUpdateTimeFromFiberToRoot(fiber, expirationTime) { } else { while (node !== null) { alternate = node.alternate; + if (__DEV__) { + if ( + alternate === null && + (node.effectTag & (Placement | Hydrating)) !== NoEffect + ) { + warnAboutUpdateOnNotYetMountedFiberInDEV(fiber); + } + } if (node.childExpirationTime < expirationTime) { node.childExpirationTime = expirationTime; if ( @@ -2901,6 +2919,60 @@ function flushRenderPhaseStrictModeWarningsInDEV() { } } +let didWarnStateUpdateForNotYetMountedComponent: Set | null = null; +function warnAboutUpdateOnNotYetMountedFiberInDEV(fiber) { + if (__DEV__) { + if ((executionContext & RenderContext) !== NoContext) { + // We let the other warning about render phase updates deal with this one. + return; + } + + const tag = fiber.tag; + if ( + tag !== IndeterminateComponent && + tag !== HostRoot && + tag !== ClassComponent && + tag !== FunctionComponent && + tag !== ForwardRef && + tag !== MemoComponent && + tag !== SimpleMemoComponent && + tag !== Block + ) { + // Only warn for user-defined components, not internal ones like Suspense. + return; + } + + // We show the whole stack but dedupe on the top component's name because + // the problematic code almost always lies inside that component. + const componentName = getComponentName(fiber.type) || 'ReactComponent'; + if (didWarnStateUpdateForNotYetMountedComponent !== null) { + if (didWarnStateUpdateForNotYetMountedComponent.has(componentName)) { + return; + } + didWarnStateUpdateForNotYetMountedComponent.add(componentName); + } else { + didWarnStateUpdateForNotYetMountedComponent = new Set([componentName]); + } + + const previousFiber = ReactCurrentFiberCurrent; + try { + setCurrentDebugFiberInDEV(fiber); + console.error( + "Can't perform a React state update on a component that hasn't mounted yet. " + + 'This indicates that you have a side-effect in your render function that ' + + 'asynchronously later calls tries to update the component. Move this work to ' + + 'useEffect instead.', + ); + } finally { + if (previousFiber) { + setCurrentDebugFiberInDEV(fiber); + } else { + resetCurrentDebugFiberInDEV(); + } + } + } +} + let didWarnStateUpdateForUnmountedComponent: Set | null = null; function warnAboutUpdateOnUnmountedFiberInDEV(fiber) { if (__DEV__) {