diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js index a27954785f7ea..daf3453314183 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js @@ -1975,5 +1975,40 @@ describe('ReactDOMServerHooks', () => { container.getElementsByTagName('span')[0].getAttribute('id'), ).not.toBeNull(); }); + + it('useOpaqueIdentifier with multiple ids in nested components', async () => { + function DivWithId({id, children}) { + return
{children}
; + } + + let setShowMore; + function App() { + const outerId = useOpaqueIdentifier(); + const innerId = useOpaqueIdentifier(); + const [showMore, _setShowMore] = useState(false); + setShowMore = _setShowMore; + return showMore ? ( + + + + ) : null; + } + + const container = document.createElement('div'); + container.innerHTML = ReactDOMServer.renderToString(); + + await act(async () => { + ReactDOM.hydrateRoot(container, ); + }); + + // Show additional content that wasn't part of the initial server- + // rendered repsonse. + await act(async () => { + setShowMore(true); + }); + const [div1, div2] = container.getElementsByTagName('div'); + expect(typeof div1.getAttribute('id')).toBe('string'); + expect(typeof div2.getAttribute('id')).toBe('string'); + }); }); }); diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index aaeac5fdb22e1..884f8cc22aa5d 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -294,7 +294,9 @@ let workInProgressRootIncludedLanes: Lanes = NoLanes; // includes unprocessed updates, not work in bailed out children. let workInProgressRootSkippedLanes: Lanes = NoLanes; // Lanes that were updated (in an interleaved event) during this render. -let workInProgressRootUpdatedLanes: Lanes = NoLanes; +let workInProgressRootInterleavedUpdatedLanes: Lanes = NoLanes; +// Lanes that were updated during the render phase (*not* an interleaved event). +let workInProgressRootRenderPhaseUpdatedLanes: Lanes = NoLanes; // Lanes that were pinged (in an interleaved event) during this render. let workInProgressRootPingedLanes: Lanes = NoLanes; @@ -454,86 +456,105 @@ export function scheduleUpdateOnFiber( eventTime: number, ): FiberRoot | null { checkForNestedUpdates(); - warnAboutRenderPhaseUpdatesInDEV(fiber); const root = markUpdateLaneFromFiberToRoot(fiber, lane); if (root === null) { return null; } - if (enableUpdaterTracking) { - if (isDevToolsPresent) { - addFiberToLanesMap(root, fiber, lane); - } - } - // Mark that the root has a pending update. markRootUpdated(root, lane, eventTime); - if (enableProfilerTimer && enableProfilerNestedUpdateScheduledHook) { - if ( - (executionContext & CommitContext) !== NoContext && - root === rootCommittingMutationOrLayoutEffects - ) { - if (fiber.mode & ProfileMode) { - let current = fiber; - while (current !== null) { - if (current.tag === Profiler) { - const {id, onNestedUpdateScheduled} = current.memoizedProps; - if (typeof onNestedUpdateScheduled === 'function') { - onNestedUpdateScheduled(id); + if ( + (executionContext & RenderContext) !== NoLanes && + root === workInProgressRoot + ) { + // This update was dispatched during the render phase. This is a mistake + // if the update originates from user space (with the exception of local + // hook updates, which are handled differently and don't reach this + // function), but there are some internal React features that use this as + // an implementation detail, like selective hydration + // and useOpaqueIdentifier. + warnAboutRenderPhaseUpdatesInDEV(fiber); + + // Track lanes that were updated during the render phase + workInProgressRootRenderPhaseUpdatedLanes = mergeLanes( + workInProgressRootRenderPhaseUpdatedLanes, + lane, + ); + } else { + // This is a normal update, scheduled from outside the render phase. For + // example, during an input event. + if (enableUpdaterTracking) { + if (isDevToolsPresent) { + addFiberToLanesMap(root, fiber, lane); + } + } + + if (enableProfilerTimer && enableProfilerNestedUpdateScheduledHook) { + if ( + (executionContext & CommitContext) !== NoContext && + root === rootCommittingMutationOrLayoutEffects + ) { + if (fiber.mode & ProfileMode) { + let current = fiber; + while (current !== null) { + if (current.tag === Profiler) { + const {id, onNestedUpdateScheduled} = current.memoizedProps; + if (typeof onNestedUpdateScheduled === 'function') { + onNestedUpdateScheduled(id); + } } + current = current.return; } - current = current.return; } } } - } - // TODO: Consolidate with `isInterleavedUpdate` check - if (root === workInProgressRoot) { - // Received an update to a tree that's in the middle of rendering. Mark - // that there was an interleaved update work on this root. Unless the - // `deferRenderPhaseUpdateToNextBatch` flag is off and this is a render - // phase update. In that case, we don't treat render phase updates as if - // they were interleaved, for backwards compat reasons. + // TODO: Consolidate with `isInterleavedUpdate` check + if (root === workInProgressRoot) { + // Received an update to a tree that's in the middle of rendering. Mark + // that there was an interleaved update work on this root. Unless the + // `deferRenderPhaseUpdateToNextBatch` flag is off and this is a render + // phase update. In that case, we don't treat render phase updates as if + // they were interleaved, for backwards compat reasons. + if ( + deferRenderPhaseUpdateToNextBatch || + (executionContext & RenderContext) === NoContext + ) { + workInProgressRootInterleavedUpdatedLanes = mergeLanes( + workInProgressRootInterleavedUpdatedLanes, + lane, + ); + } + if (workInProgressRootExitStatus === RootSuspendedWithDelay) { + // The root already suspended with a delay, which means this render + // definitely won't finish. Since we have a new update, let's mark it as + // suspended now, right before marking the incoming update. This has the + // effect of interrupting the current render and switching to the update. + // TODO: Make sure this doesn't override pings that happen while we've + // already started rendering. + markRootSuspended(root, workInProgressRootRenderLanes); + } + } + + ensureRootIsScheduled(root, eventTime); if ( - deferRenderPhaseUpdateToNextBatch || - (executionContext & RenderContext) === NoContext + lane === SyncLane && + executionContext === NoContext && + (fiber.mode & ConcurrentMode) === NoMode && + // Treat `act` as if it's inside `batchedUpdates`, even in legacy mode. + !(__DEV__ && ReactCurrentActQueue.isBatchingLegacy) ) { - workInProgressRootUpdatedLanes = mergeLanes( - workInProgressRootUpdatedLanes, - lane, - ); - } - if (workInProgressRootExitStatus === RootSuspendedWithDelay) { - // The root already suspended with a delay, which means this render - // definitely won't finish. Since we have a new update, let's mark it as - // suspended now, right before marking the incoming update. This has the - // effect of interrupting the current render and switching to the update. - // TODO: Make sure this doesn't override pings that happen while we've - // already started rendering. - markRootSuspended(root, workInProgressRootRenderLanes); + // Flush the synchronous work now, unless we're already working or inside + // a batch. This is intentionally inside scheduleUpdateOnFiber instead of + // scheduleCallbackForFiber to preserve the ability to schedule a callback + // without immediately flushing it. We only do this for user-initiated + // updates, to preserve historical behavior of legacy mode. + resetRenderTimer(); + flushSyncCallbacksOnlyInLegacyMode(); } } - - ensureRootIsScheduled(root, eventTime); - if ( - lane === SyncLane && - executionContext === NoContext && - (fiber.mode & ConcurrentMode) === NoMode && - // Treat `act` as if it's inside `batchedUpdates`, even in legacy mode. - !(__DEV__ && ReactCurrentActQueue.isBatchingLegacy) - ) { - // Flush the synchronous work now, unless we're already working or inside - // a batch. This is intentionally inside scheduleUpdateOnFiber instead of - // scheduleCallbackForFiber to preserve the ability to schedule a callback - // without immediately flushing it. We only do this for user-initiated - // updates, to preserve historical behavior of legacy mode. - resetRenderTimer(); - flushSyncCallbacksOnlyInLegacyMode(); - } - return root; } @@ -865,7 +886,25 @@ function recoverFromConcurrentError(root, errorRetryLanes) { clearContainer(root.containerInfo); } - const exitStatus = renderRootSync(root, errorRetryLanes); + let exitStatus; + + const MAX_ERROR_RETRY_ATTEMPTS = 50; + for (let i = 0; i < MAX_ERROR_RETRY_ATTEMPTS; i++) { + exitStatus = renderRootSync(root, errorRetryLanes); + if ( + exitStatus === RootErrored && + workInProgressRootRenderPhaseUpdatedLanes !== NoLanes + ) { + // There was a render phase update during this render. This was likely a + // useOpaqueIdentifier hook upgrading itself to a client ID. Try rendering + // again. This time, the component will use a client ID and will proceed + // without throwing. If multiple IDs upgrade as a result of the same + // update, we will have to do multiple render passes. To protect against + // an inifinite loop, eventually we'll give up. + continue; + } + break; + } executionContext = prevExecutionContext; @@ -1042,7 +1081,10 @@ function markRootSuspended(root, suspendedLanes) { // TODO: Lol maybe there's a better way to factor this besides this // obnoxiously named function :) suspendedLanes = removeLanes(suspendedLanes, workInProgressRootPingedLanes); - suspendedLanes = removeLanes(suspendedLanes, workInProgressRootUpdatedLanes); + suspendedLanes = removeLanes( + suspendedLanes, + workInProgressRootInterleavedUpdatedLanes, + ); markRootSuspended_dontCallThisOneDirectly(root, suspendedLanes); } @@ -1068,19 +1110,6 @@ function performSyncWorkOnRoot(root) { let exitStatus = renderRootSync(root, lanes); if (root.tag !== LegacyRoot && exitStatus === RootErrored) { - const prevExecutionContext = executionContext; - executionContext |= RetryAfterError; - - // If an error occurred during hydration, - // discard server response and fall back to client side render. - if (root.isDehydrated) { - root.isDehydrated = false; - if (__DEV__) { - errorHydratingContainer(root.containerInfo); - } - clearContainer(root.containerInfo); - } - // If something threw an error, try rendering one more time. We'll render // synchronously to block concurrent data mutations, and we'll includes // all pending updates are included. If it still fails after the second @@ -1088,10 +1117,8 @@ function performSyncWorkOnRoot(root) { const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = renderRootSync(root, lanes); + exitStatus = recoverFromConcurrentError(root, errorRetryLanes); } - - executionContext = prevExecutionContext; } if (exitStatus === RootFatalErrored) { @@ -1300,7 +1327,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) { workInProgressRootExitStatus = RootIncomplete; workInProgressRootFatalError = null; workInProgressRootSkippedLanes = NoLanes; - workInProgressRootUpdatedLanes = NoLanes; + workInProgressRootInterleavedUpdatedLanes = NoLanes; + workInProgressRootRenderPhaseUpdatedLanes = NoLanes; workInProgressRootPingedLanes = NoLanes; enqueueInterleavedUpdates(); @@ -1443,7 +1471,7 @@ export function renderDidSuspendDelayIfPossible(): void { if ( workInProgressRoot !== null && (includesNonIdleWork(workInProgressRootSkippedLanes) || - includesNonIdleWork(workInProgressRootUpdatedLanes)) + includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)) ) { // Mark the current render as suspended so that we switch to working on // the updates that were skipped. Usually we only suspend at the end of @@ -2697,7 +2725,6 @@ function warnAboutRenderPhaseUpdatesInDEV(fiber) { if (__DEV__) { if ( ReactCurrentDebugFiberIsRenderingInDEV && - (executionContext & RenderContext) !== NoContext && !getIsUpdatingOpaqueValueInRenderPhaseInDEV() ) { switch (fiber.tag) { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 67984298a5dea..db7a3de837b40 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -294,7 +294,9 @@ let workInProgressRootIncludedLanes: Lanes = NoLanes; // includes unprocessed updates, not work in bailed out children. let workInProgressRootSkippedLanes: Lanes = NoLanes; // Lanes that were updated (in an interleaved event) during this render. -let workInProgressRootUpdatedLanes: Lanes = NoLanes; +let workInProgressRootInterleavedUpdatedLanes: Lanes = NoLanes; +// Lanes that were updated during the render phase (*not* an interleaved event). +let workInProgressRootRenderPhaseUpdatedLanes: Lanes = NoLanes; // Lanes that were pinged (in an interleaved event) during this render. let workInProgressRootPingedLanes: Lanes = NoLanes; @@ -454,86 +456,105 @@ export function scheduleUpdateOnFiber( eventTime: number, ): FiberRoot | null { checkForNestedUpdates(); - warnAboutRenderPhaseUpdatesInDEV(fiber); const root = markUpdateLaneFromFiberToRoot(fiber, lane); if (root === null) { return null; } - if (enableUpdaterTracking) { - if (isDevToolsPresent) { - addFiberToLanesMap(root, fiber, lane); - } - } - // Mark that the root has a pending update. markRootUpdated(root, lane, eventTime); - if (enableProfilerTimer && enableProfilerNestedUpdateScheduledHook) { - if ( - (executionContext & CommitContext) !== NoContext && - root === rootCommittingMutationOrLayoutEffects - ) { - if (fiber.mode & ProfileMode) { - let current = fiber; - while (current !== null) { - if (current.tag === Profiler) { - const {id, onNestedUpdateScheduled} = current.memoizedProps; - if (typeof onNestedUpdateScheduled === 'function') { - onNestedUpdateScheduled(id); + if ( + (executionContext & RenderContext) !== NoLanes && + root === workInProgressRoot + ) { + // This update was dispatched during the render phase. This is a mistake + // if the update originates from user space (with the exception of local + // hook updates, which are handled differently and don't reach this + // function), but there are some internal React features that use this as + // an implementation detail, like selective hydration + // and useOpaqueIdentifier. + warnAboutRenderPhaseUpdatesInDEV(fiber); + + // Track lanes that were updated during the render phase + workInProgressRootRenderPhaseUpdatedLanes = mergeLanes( + workInProgressRootRenderPhaseUpdatedLanes, + lane, + ); + } else { + // This is a normal update, scheduled from outside the render phase. For + // example, during an input event. + if (enableUpdaterTracking) { + if (isDevToolsPresent) { + addFiberToLanesMap(root, fiber, lane); + } + } + + if (enableProfilerTimer && enableProfilerNestedUpdateScheduledHook) { + if ( + (executionContext & CommitContext) !== NoContext && + root === rootCommittingMutationOrLayoutEffects + ) { + if (fiber.mode & ProfileMode) { + let current = fiber; + while (current !== null) { + if (current.tag === Profiler) { + const {id, onNestedUpdateScheduled} = current.memoizedProps; + if (typeof onNestedUpdateScheduled === 'function') { + onNestedUpdateScheduled(id); + } } + current = current.return; } - current = current.return; } } } - } - // TODO: Consolidate with `isInterleavedUpdate` check - if (root === workInProgressRoot) { - // Received an update to a tree that's in the middle of rendering. Mark - // that there was an interleaved update work on this root. Unless the - // `deferRenderPhaseUpdateToNextBatch` flag is off and this is a render - // phase update. In that case, we don't treat render phase updates as if - // they were interleaved, for backwards compat reasons. + // TODO: Consolidate with `isInterleavedUpdate` check + if (root === workInProgressRoot) { + // Received an update to a tree that's in the middle of rendering. Mark + // that there was an interleaved update work on this root. Unless the + // `deferRenderPhaseUpdateToNextBatch` flag is off and this is a render + // phase update. In that case, we don't treat render phase updates as if + // they were interleaved, for backwards compat reasons. + if ( + deferRenderPhaseUpdateToNextBatch || + (executionContext & RenderContext) === NoContext + ) { + workInProgressRootInterleavedUpdatedLanes = mergeLanes( + workInProgressRootInterleavedUpdatedLanes, + lane, + ); + } + if (workInProgressRootExitStatus === RootSuspendedWithDelay) { + // The root already suspended with a delay, which means this render + // definitely won't finish. Since we have a new update, let's mark it as + // suspended now, right before marking the incoming update. This has the + // effect of interrupting the current render and switching to the update. + // TODO: Make sure this doesn't override pings that happen while we've + // already started rendering. + markRootSuspended(root, workInProgressRootRenderLanes); + } + } + + ensureRootIsScheduled(root, eventTime); if ( - deferRenderPhaseUpdateToNextBatch || - (executionContext & RenderContext) === NoContext + lane === SyncLane && + executionContext === NoContext && + (fiber.mode & ConcurrentMode) === NoMode && + // Treat `act` as if it's inside `batchedUpdates`, even in legacy mode. + !(__DEV__ && ReactCurrentActQueue.isBatchingLegacy) ) { - workInProgressRootUpdatedLanes = mergeLanes( - workInProgressRootUpdatedLanes, - lane, - ); - } - if (workInProgressRootExitStatus === RootSuspendedWithDelay) { - // The root already suspended with a delay, which means this render - // definitely won't finish. Since we have a new update, let's mark it as - // suspended now, right before marking the incoming update. This has the - // effect of interrupting the current render and switching to the update. - // TODO: Make sure this doesn't override pings that happen while we've - // already started rendering. - markRootSuspended(root, workInProgressRootRenderLanes); + // Flush the synchronous work now, unless we're already working or inside + // a batch. This is intentionally inside scheduleUpdateOnFiber instead of + // scheduleCallbackForFiber to preserve the ability to schedule a callback + // without immediately flushing it. We only do this for user-initiated + // updates, to preserve historical behavior of legacy mode. + resetRenderTimer(); + flushSyncCallbacksOnlyInLegacyMode(); } } - - ensureRootIsScheduled(root, eventTime); - if ( - lane === SyncLane && - executionContext === NoContext && - (fiber.mode & ConcurrentMode) === NoMode && - // Treat `act` as if it's inside `batchedUpdates`, even in legacy mode. - !(__DEV__ && ReactCurrentActQueue.isBatchingLegacy) - ) { - // Flush the synchronous work now, unless we're already working or inside - // a batch. This is intentionally inside scheduleUpdateOnFiber instead of - // scheduleCallbackForFiber to preserve the ability to schedule a callback - // without immediately flushing it. We only do this for user-initiated - // updates, to preserve historical behavior of legacy mode. - resetRenderTimer(); - flushSyncCallbacksOnlyInLegacyMode(); - } - return root; } @@ -865,7 +886,25 @@ function recoverFromConcurrentError(root, errorRetryLanes) { clearContainer(root.containerInfo); } - const exitStatus = renderRootSync(root, errorRetryLanes); + let exitStatus; + + const MAX_ERROR_RETRY_ATTEMPTS = 50; + for (let i = 0; i < MAX_ERROR_RETRY_ATTEMPTS; i++) { + exitStatus = renderRootSync(root, errorRetryLanes); + if ( + exitStatus === RootErrored && + workInProgressRootRenderPhaseUpdatedLanes !== NoLanes + ) { + // There was a render phase update during this render. This was likely a + // useOpaqueIdentifier hook upgrading itself to a client ID. Try rendering + // again. This time, the component will use a client ID and will proceed + // without throwing. If multiple IDs upgrade as a result of the same + // update, we will have to do multiple render passes. To protect against + // an inifinite loop, eventually we'll give up. + continue; + } + break; + } executionContext = prevExecutionContext; @@ -1042,7 +1081,10 @@ function markRootSuspended(root, suspendedLanes) { // TODO: Lol maybe there's a better way to factor this besides this // obnoxiously named function :) suspendedLanes = removeLanes(suspendedLanes, workInProgressRootPingedLanes); - suspendedLanes = removeLanes(suspendedLanes, workInProgressRootUpdatedLanes); + suspendedLanes = removeLanes( + suspendedLanes, + workInProgressRootInterleavedUpdatedLanes, + ); markRootSuspended_dontCallThisOneDirectly(root, suspendedLanes); } @@ -1068,19 +1110,6 @@ function performSyncWorkOnRoot(root) { let exitStatus = renderRootSync(root, lanes); if (root.tag !== LegacyRoot && exitStatus === RootErrored) { - const prevExecutionContext = executionContext; - executionContext |= RetryAfterError; - - // If an error occurred during hydration, - // discard server response and fall back to client side render. - if (root.isDehydrated) { - root.isDehydrated = false; - if (__DEV__) { - errorHydratingContainer(root.containerInfo); - } - clearContainer(root.containerInfo); - } - // If something threw an error, try rendering one more time. We'll render // synchronously to block concurrent data mutations, and we'll includes // all pending updates are included. If it still fails after the second @@ -1088,10 +1117,8 @@ function performSyncWorkOnRoot(root) { const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = renderRootSync(root, lanes); + exitStatus = recoverFromConcurrentError(root, errorRetryLanes); } - - executionContext = prevExecutionContext; } if (exitStatus === RootFatalErrored) { @@ -1300,7 +1327,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) { workInProgressRootExitStatus = RootIncomplete; workInProgressRootFatalError = null; workInProgressRootSkippedLanes = NoLanes; - workInProgressRootUpdatedLanes = NoLanes; + workInProgressRootInterleavedUpdatedLanes = NoLanes; + workInProgressRootRenderPhaseUpdatedLanes = NoLanes; workInProgressRootPingedLanes = NoLanes; enqueueInterleavedUpdates(); @@ -1443,7 +1471,7 @@ export function renderDidSuspendDelayIfPossible(): void { if ( workInProgressRoot !== null && (includesNonIdleWork(workInProgressRootSkippedLanes) || - includesNonIdleWork(workInProgressRootUpdatedLanes)) + includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)) ) { // Mark the current render as suspended so that we switch to working on // the updates that were skipped. Usually we only suspend at the end of @@ -2697,7 +2725,6 @@ function warnAboutRenderPhaseUpdatesInDEV(fiber) { if (__DEV__) { if ( ReactCurrentDebugFiberIsRenderingInDEV && - (executionContext & RenderContext) !== NoContext && !getIsUpdatingOpaqueValueInRenderPhaseInDEV() ) { switch (fiber.tag) { diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js index 716771a3778dd..cc842bd16d22f 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js @@ -1915,6 +1915,65 @@ describe('ReactIncrementalErrorHandling', () => { expect(root).toMatchRenderedOutput('Everything is fine.'); }); + it("does not infinite loop if there's a render phase update in the same render as an error", async () => { + // useOpaqueIdentifier uses an render phase update as an implementation + // detail. When an error is accompanied by a render phase update, we assume + // that it comes from useOpaqueIdentifier, because render phase updates + // triggered from userspace are not allowed (we log a warning). So we keep + // attempting to recover until no more opaque identifiers need to be + // upgraded. However, we should give up after some point to prevent an + // infinite loop in the case where there is (by accident) a render phase + // triggered from userspace. + + spyOnDev(console, 'error'); + + let numberOfThrows = 0; + + let setStateInRenderPhase; + function Child() { + const [, setState] = React.useState(0); + setStateInRenderPhase = setState; + return 'All good'; + } + + function App({shouldThrow}) { + if (shouldThrow) { + setStateInRenderPhase(); + numberOfThrows++; + throw new Error('Oops!'); + } + return ; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(root).toMatchRenderedOutput('All good'); + + let error; + try { + await act(async () => { + root.render(); + }); + } catch (e) { + error = e; + } + + expect(error.message).toBe('Oops!'); + expect(numberOfThrows < 100).toBe(true); + + if (__DEV__) { + expect(console.error).toHaveBeenCalledTimes(2); + expect(console.error.calls.argsFor(0)[0]).toContain( + 'Cannot update a component (`%s`) while rendering a different component', + ); + expect(console.error.calls.argsFor(1)[0]).toContain( + 'The above error occurred in the component', + ); + } + }); + if (global.__PERSISTENT__) { it('regression test: should fatal if error is thrown at the root', () => { const root = ReactNoop.createRoot();