diff --git a/packages/react-dom/src/__tests__/ReactUpdates-test.js b/packages/react-dom/src/__tests__/ReactUpdates-test.js
index cf2e958d4511..2e79c33644e9 100644
--- a/packages/react-dom/src/__tests__/ReactUpdates-test.js
+++ b/packages/react-dom/src/__tests__/ReactUpdates-test.js
@@ -2006,6 +2006,115 @@ describe('ReactUpdates', () => {
]);
});
+ it('warns instead of throwing when infinite Suspense ping loop is detected via enableInfiniteRenderLoopDetection during commit phase', async () => {
+ if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
+ return;
+ }
+
+ // When a Suspense child throws a thenable, React registers two listeners:
+ // 1. ping (attachPingListener, render) → pingSuspendedRoot → markRootPinged
+ // 2. retry (attachSuspenseRetryListeners, commit) → resolveRetryWakeable
+ //
+ // The ping path calls throwIfInfiniteUpdateLoopDetected(true) via
+ // markRootPinged WITHOUT a prior getRootForUpdatedFiber(false) check.
+ // When this fires during CommitContext (not RenderContext),
+ // the isFromInfiniteRenderLoopDetectionInstrumentation=true parameter
+ // ensures we warn instead of throw.
+ //
+ // Without the fix (passing false), the condition
+ // false || (executionContext & RenderContext && ...)
+ // evaluates to false in CommitContext, causing a throw.
+ let currentResolve = null;
+ let shouldStop = false;
+
+ function App() {
+ const [, setState] = React.useState(0);
+
+ React.useLayoutEffect(() => {
+ if (shouldStop) {
+ return;
+ }
+ // Resolve the suspended thenable during commit phase (CommitContext).
+ // The ping callback (registered first during render) fires first,
+ // triggering markRootPinged → throwIfInfiniteUpdateLoopDetected(true).
+ if (currentResolve !== null) {
+ const resolve = currentResolve;
+ currentResolve = null;
+ resolve();
+ }
+ // Schedule a sync update to ensure nestedUpdateKind is
+ // NESTED_UPDATE_SYNC_LANE at commitRootImpl epilogue.
+ setState(n => n + 1);
+ });
+
+ return (
+
+
+
+ );
+ }
+
+ function SuspendingChild() {
+ if (shouldStop) {
+ return null;
+ }
+ // Each render throws a new thenable. React calls .then() on it twice
+ // (ping during render, retry during commit). We collect all callbacks
+ // so resolve() fires them in registration order: ping first.
+ const callbacks = [];
+ const thenable = {
+ then(onFulfilled) {
+ callbacks.push(onFulfilled);
+ currentResolve = () => {
+ for (let i = 0; i < callbacks.length; i++) {
+ callbacks[i]();
+ }
+ };
+ },
+ };
+
+ throw thenable;
+ }
+
+ const container = document.createElement('div');
+ const errors = [];
+ const root = ReactDOMClient.createRoot(container, {
+ onUncaughtError: error => {
+ errors.push(error.message);
+ },
+ });
+
+ const originalConsoleError = console.error;
+ console.error = e => {
+ if (
+ typeof e === 'string' &&
+ e.startsWith(
+ 'Maximum update depth exceeded. This could be an infinite loop.',
+ )
+ ) {
+ // Stop the loop after the first warning so act() can finish.
+ shouldStop = true;
+ }
+ };
+
+ try {
+ await act(() => {
+ root.render();
+ });
+ } finally {
+ console.error = originalConsoleError;
+ }
+
+ // With the fix (throwIfInfiniteUpdateLoopDetected(true) in markRootPinged):
+ // the loop is discovered via enableInfiniteRenderLoopDetection instrumentation
+ // and produces a warning.
+ // Without the fix (throwIfInfiniteUpdateLoopDetected(false)):
+ // the same check throws because executionContext is CommitContext, not
+ // RenderContext.
+ expect(shouldStop).toBe(true);
+ expect(errors).toEqual([]);
+ });
+
it('prevents infinite update loop triggered by too many updates in ref callbacks', async () => {
let scheduleUpdate;
function TooManyRefUpdates() {
diff --git a/packages/react-reconciler/src/ReactFiberConcurrentUpdates.js b/packages/react-reconciler/src/ReactFiberConcurrentUpdates.js
index 05aeb3cfbb37..f8c67a1c3fed 100644
--- a/packages/react-reconciler/src/ReactFiberConcurrentUpdates.js
+++ b/packages/react-reconciler/src/ReactFiberConcurrentUpdates.js
@@ -254,7 +254,7 @@ function getRootForUpdatedFiber(sourceFiber: Fiber): FiberRoot | null {
// current behavior we've used for several release cycles. Consider not
// performing this check if the updated fiber already unmounted, since it's
// not possible for that to cause an infinite update loop.
- throwIfInfiniteUpdateLoopDetected();
+ throwIfInfiniteUpdateLoopDetected(false);
// When a setState happens, we must ensure the root is scheduled. Because
// update queues do not have a backpointer to the root, the only way to do
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js
index 9a3953c1b5a2..d1153a201dfc 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js
@@ -1754,7 +1754,7 @@ function markRootUpdated(root: FiberRoot, updatedLanes: Lanes) {
didIncludeCommitPhaseUpdate = true;
}
- throwIfInfiniteUpdateLoopDetected();
+ throwIfInfiniteUpdateLoopDetected(true);
}
}
@@ -1773,7 +1773,7 @@ function markRootPinged(root: FiberRoot, pingedLanes: Lanes) {
didIncludeCommitPhaseUpdate = true;
}
- throwIfInfiniteUpdateLoopDetected();
+ throwIfInfiniteUpdateLoopDetected(true);
}
}
@@ -5175,7 +5175,9 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) {
retryTimedOutBoundary(boundaryFiber, retryLane);
}
-export function throwIfInfiniteUpdateLoopDetected() {
+export function throwIfInfiniteUpdateLoopDetected(
+ isFromInfiniteRenderLoopDetectionInstrumentation: boolean,
+) {
if (nestedUpdateCount > NESTED_UPDATE_LIMIT) {
nestedUpdateCount = 0;
nestedPassiveUpdateCount = 0;
@@ -5187,7 +5189,10 @@ export function throwIfInfiniteUpdateLoopDetected() {
if (enableInfiniteRenderLoopDetection) {
if (updateKind === NESTED_UPDATE_SYNC_LANE) {
- if (executionContext & RenderContext && workInProgressRoot !== null) {
+ if (
+ isFromInfiniteRenderLoopDetectionInstrumentation ||
+ (executionContext & RenderContext && workInProgressRoot !== null)
+ ) {
// This loop was identified only because of the instrumentation gated with enableInfiniteRenderLoopDetection, warn instead of throwing.
if (__DEV__) {
console.error(