@@ -1793,7 +1793,14 @@ describe('ReactUpdates', () => {
17931793 } ) ;
17941794
17951795 it ( "warns about potential infinite loop if there's a synchronous render phase update on another component" , async ( ) => {
1796- if ( ! __DEV__ || gate ( flags => ! flags . enableInfiniteRenderLoopDetection ) ) {
1796+ if (
1797+ ! __DEV__ ||
1798+ gate (
1799+ flags =>
1800+ ! flags . enableInfiniteRenderLoopDetection ||
1801+ flags . enableInfiniteRenderLoopDetectionForceThrow ,
1802+ )
1803+ ) {
17971804 return ;
17981805 }
17991806 let setState ;
@@ -1831,7 +1838,14 @@ describe('ReactUpdates', () => {
18311838 } ) ;
18321839
18331840 it ( "warns about potential infinite loop if there's an async render phase update on another component" , async ( ) => {
1834- if ( ! __DEV__ || gate ( flags => ! flags . enableInfiniteRenderLoopDetection ) ) {
1841+ if (
1842+ ! __DEV__ ||
1843+ gate (
1844+ flags =>
1845+ ! flags . enableInfiniteRenderLoopDetection ||
1846+ flags . enableInfiniteRenderLoopDetectionForceThrow ,
1847+ )
1848+ ) {
18351849 return ;
18361850 }
18371851 let setState ;
@@ -2007,7 +2021,14 @@ describe('ReactUpdates', () => {
20072021 } ) ;
20082022
20092023 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 ) ) {
2024+ if (
2025+ ! __DEV__ ||
2026+ gate (
2027+ flags =>
2028+ ! flags . enableInfiniteRenderLoopDetection ||
2029+ flags . enableInfiniteRenderLoopDetectionForceThrow ,
2030+ )
2031+ ) {
20112032 return ;
20122033 }
20132034
@@ -2115,6 +2136,133 @@ describe('ReactUpdates', () => {
21152136 expect ( errors ) . toEqual ( [ ] ) ;
21162137 } ) ;
21172138
2139+ // @gate enableInfiniteRenderLoopDetection && enableInfiniteRenderLoopDetectionForceThrow
2140+ it ( 'throws when sync render-phase update loop is detected with force-throw enabled' , async ( ) => {
2141+ // Render-phase setState on another component's hook produces a sync
2142+ // recursive update. With ForceThrow enabled this should throw via
2143+ // throwForcedInfiniteRenderLoopError instead of only warning in DEV.
2144+ let setState ;
2145+ let shouldStop = false ;
2146+ function App ( ) {
2147+ const [ , _setState ] = React . useState ( 0 ) ;
2148+ setState = _setState ;
2149+ return < Child /> ;
2150+ }
2151+
2152+ function Child ( ) {
2153+ if ( shouldStop ) {
2154+ return null ;
2155+ }
2156+ setState ( n => n + 1 ) ;
2157+ return null ;
2158+ }
2159+
2160+ const container = document . createElement ( 'div' ) ;
2161+ const errors = [ ] ;
2162+ const captureError = error => {
2163+ errors . push ( error . message ) ;
2164+ // Stop scheduling new updates so the test (and the gate-off variant
2165+ // where the legacy error path is recoverable) can terminate cleanly
2166+ // without tripping the babel infinite-loop guard.
2167+ shouldStop = true ;
2168+ } ;
2169+ const root = ReactDOMClient . createRoot ( container , {
2170+ onUncaughtError : captureError ,
2171+ onRecoverableError : captureError ,
2172+ onCaughtError : captureError ,
2173+ } ) ;
2174+
2175+ // The render-phase setState path also produces a dev-only "Cannot update
2176+ // a component while rendering a different component" console.error on
2177+ // every recursion. Swallow those so the test framework doesn't require
2178+ // us to assert each one.
2179+ const originalConsoleError = console . error ;
2180+ console . error = msg => {
2181+ if (
2182+ typeof msg === 'string' &&
2183+ msg . startsWith ( 'Cannot update a component' )
2184+ ) {
2185+ return ;
2186+ }
2187+ originalConsoleError ( msg ) ;
2188+ } ;
2189+ try {
2190+ await act ( ( ) => {
2191+ root . render ( < App /> ) ;
2192+ } ) ;
2193+ } finally {
2194+ console . error = originalConsoleError ;
2195+ }
2196+
2197+ expect ( errors . length ) . toBeGreaterThanOrEqual ( 1 ) ;
2198+ expect ( errors [ 0 ] ) . toContain (
2199+ 'Maximum update depth exceeded. This could be an infinite loop.' ,
2200+ ) ;
2201+ } ) ;
2202+
2203+ // @gate enableInfiniteRenderLoopDetection && enableInfiniteRenderLoopDetectionForceThrow
2204+ it ( 'throws when phase-spawn update loop is detected with force-throw enabled' , async ( ) => {
2205+ // Wrapping the initial render in startTransition makes the render-phase
2206+ // setState inherit a non-sync transition lane. After commit, the next
2207+ // render is non-sync, so the loop detector classifies the recursion as
2208+ // NESTED_UPDATE_PHASE_SPAWN (rather than SYNC_LANE). With ForceThrow
2209+ // enabled, this branch should throw via throwForcedInfiniteRenderLoopError
2210+ // instead of only warning in DEV.
2211+ let setState ;
2212+ let shouldStop = false ;
2213+ // Hard cap on Child renders. Without enableInfiniteRenderLoopDetection,
2214+ // the PHASE_SPAWN branch is gated off entirely, so no throw fires and
2215+ // the loop would otherwise run until the babel infinite-loop guard.
2216+ let renderCount = 0 ;
2217+ const RENDER_CAP = 100 ;
2218+ function App ( ) {
2219+ const [ , _setState ] = React . useState ( 0 ) ;
2220+ setState = _setState ;
2221+ return < Child /> ;
2222+ }
2223+
2224+ function Child ( ) {
2225+ if ( shouldStop || renderCount >= RENDER_CAP ) {
2226+ return null ;
2227+ }
2228+ renderCount ++ ;
2229+ setState ( n => n + 1 ) ;
2230+ return null ;
2231+ }
2232+
2233+ const container = document . createElement ( 'div' ) ;
2234+ const errors = [ ] ;
2235+ const root = ReactDOMClient . createRoot ( container , {
2236+ onUncaughtError : error => {
2237+ errors . push ( error . message ) ;
2238+ shouldStop = true ;
2239+ } ,
2240+ } ) ;
2241+
2242+ const originalConsoleError = console . error ;
2243+ console . error = msg => {
2244+ if (
2245+ typeof msg === 'string' &&
2246+ msg . startsWith ( 'Cannot update a component' )
2247+ ) {
2248+ return ;
2249+ }
2250+ originalConsoleError ( msg ) ;
2251+ } ;
2252+ try {
2253+ await act ( ( ) => {
2254+ React . startTransition ( ( ) => root . render ( < App /> ) ) ;
2255+ } ) ;
2256+ } finally {
2257+ console . error = originalConsoleError ;
2258+ }
2259+
2260+ expect ( errors . length ) . toBeGreaterThanOrEqual ( 1 ) ;
2261+ expect ( errors [ 0 ] ) . toContain (
2262+ 'Maximum update depth exceeded. This could be an infinite loop.' ,
2263+ ) ;
2264+ } ) ;
2265+
21182266 it ( 'prevents infinite update loop triggered by too many updates in ref callbacks' , async ( ) => {
21192267 let scheduleUpdate ;
21202268 function TooManyRefUpdates ( ) {
0 commit comments