2006
]);
2007
});
2008
2009
+ it('warns instead of throwing when infinite Suspense ping loop is detected via enableInfiniteRenderLoopDetection during commit phase', async () => {
2010
+ if (!__DEV__ || gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
2011
+ return;
2012
+ }
2013
+
2014
+ // When a Suspense child throws a thenable, React registers two listeners:
2015
+ // 1. ping (attachPingListener, render) → pingSuspendedRoot → markRootPinged
2016
+ // 2. retry (attachSuspenseRetryListeners, commit) → resolveRetryWakeable
2017
+ //
2018
+ // The ping path calls throwIfInfiniteUpdateLoopDetected(true) via
2019
+ // markRootPinged WITHOUT a prior getRootForUpdatedFiber(false) check.
2020
+ // When this fires during CommitContext (not RenderContext),
2021
+ // the isFromInfiniteRenderLoopDetectionInstrumentation=true parameter
2022
+ // ensures we warn instead of throw.
2023
+ //
2024
+ // Without the fix (passing false), the condition
2025
+ // false || (executionContext & RenderContext && ...)
2026
+ // evaluates to false in CommitContext, causing a throw.
2027
+ let currentResolve = null;
2028
+ let shouldStop = false;
2029
+
2030
+ function App() {
2031
+ const [, setState] = React.useState(0);
2032
+
2033
+ React.useLayoutEffect(() => {
2034
+ if (shouldStop) {
2035
+ return;
2036
+ }
2037
+ // Resolve the suspended thenable during commit phase (CommitContext).
2038
+ // The ping callback (registered first during render) fires first,
2039
+ // triggering markRootPinged → throwIfInfiniteUpdateLoopDetected(true).
2040
+ if (currentResolve !== null) {
2041
+ const resolve = currentResolve;
2042
+ currentResolve = null;
2043
+ resolve();
2044
+ }
2045
+ // Schedule a sync update to ensure nestedUpdateKind is
2046
+ // NESTED_UPDATE_SYNC_LANE at commitRootImpl epilogue.
2047
+ setState(n => n + 1);
2048
+ });
2049
+
2050
+ return (
2051
+ <React.Suspense fallback="loading">
2052
+ <SuspendingChild />
2053
+ </React.Suspense>
2054
+ );
2055
+ }
2056
+
2057
+ function SuspendingChild() {
2058
+ if (shouldStop) {
2059
+ return null;
2060
+ }
2061
+ // Each render throws a new thenable. React calls .then() on it twice
2062
+ // (ping during render, retry during commit). We collect all callbacks
2063
+ // so resolve() fires them in registration order: ping first.
2064
+ const callbacks = [];
2065
+ const thenable = {
2066
+ then(onFulfilled) {
2067
+ callbacks.push(onFulfilled);
2068
+ currentResolve = () => {
2069
+ for (let i = 0; i < callbacks.length; i++) {
2070
+ callbacks[i]();
2071
+ }
2072
+ };
2073
+ },
2074
+ };
2075
+
2076
+ throw thenable;
2077
+ }
2078
+
2079
+ const container = document.createElement('div');
2080
+ const errors = [];
2081
+ const root = ReactDOMClient.createRoot(container, {
2082
+ onUncaughtError: error => {
2083
+ errors.push(error.message);
2084
+ },
2085
+ });
2086
+
2087
+ const originalConsoleError = console.error;
2088
+ console.error = e => {
2089
+ if (
2090
+ typeof e === 'string' &&
2091
+ e.startsWith(
2092
+ 'Maximum update depth exceeded. This could be an infinite loop.',
2093
+ )
2094
+ ) {
2095
+ // Stop the loop after the first warning so act() can finish.
2096
+ shouldStop = true;
2097
+ }
2098
+ };
2099
+
2100
+ try {
2101
+ await act(() => {
2102
+ root.render(<App />);
2103
+ });
2104
+ } finally {
2105
+ console.error = originalConsoleError;
2106
+ }
2107
+
2108
+ // With the fix (throwIfInfiniteUpdateLoopDetected(true) in markRootPinged):
2109
+ // the loop is discovered via enableInfiniteRenderLoopDetection instrumentation
2110
+ // and produces a warning.
2111
+ // Without the fix (throwIfInfiniteUpdateLoopDetected(false)):
2112
+ // the same check throws because executionContext is CommitContext, not
2113
+ // RenderContext.
2114
+ expect(shouldStop).toBe(true);
2115
+ expect(errors).toEqual([]);
2116
+ });
2117
+
2118
it('prevents infinite update loop triggered by too many updates in ref callbacks', async () => {
2119
let scheduleUpdate;
2120
function TooManyRefUpdates() {