Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions packages/react-dom/src/__tests__/ReactUpdates-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this use @gate pragma?

Copy link
Copy Markdown
Contributor Author

@hoxyq hoxyq Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not at the current state. If we use @gate pragma, then the test has to fail when the gating conditions are not met. It fails, but because of the infinite loop, which is prevented by Jest, and such failure is not considered as "good" failure.

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 (
<React.Suspense fallback="loading">
<SuspendingChild />
</React.Suspense>
);
}

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(<App />);
});
} 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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions packages/react-reconciler/src/ReactFiberWorkLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -1754,7 +1754,7 @@ function markRootUpdated(root: FiberRoot, updatedLanes: Lanes) {
didIncludeCommitPhaseUpdate = true;
}

throwIfInfiniteUpdateLoopDetected();
throwIfInfiniteUpdateLoopDetected(true);
}
}

Expand All @@ -1773,7 +1773,7 @@ function markRootPinged(root: FiberRoot, pingedLanes: Lanes) {
didIncludeCommitPhaseUpdate = true;
}

throwIfInfiniteUpdateLoopDetected();
throwIfInfiniteUpdateLoopDetected(true);
}
}

Expand Down Expand Up @@ -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;
Expand All @@ -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(
Expand Down
Loading