Repository: vercel/next.js
Pull request: Model abandoning by signal

Stacked on #89970

The staged rendering class will be used for more soon in more contexts (prod prerendering for runtime prefetches for example). This changes the interface for render abandonment to not use the presence of the render abort signal

Changed files:
filename: packages/next/src/server/app-render/app-render.tsx
patch: @@ -3295,9 +3295,11 @@ async function renderWithRestartOnCacheMissInDev(
 
   const initialReactController = new AbortController()
   const initialDataController = new AbortController() // Controls hanging promises we create
+  const initialAbandonController = new AbortController() // Controls whether this render is abandoned
   const initialStageController = new StagedRenderingController(
     initialDataController.signal,
-    hasRuntimePrefetch
+    hasRuntimePrefetch,
+    initialAbandonController
   )
 
   requestStore.prerenderResumeDataCache = prerenderResumeDataCache
@@ -3372,7 +3374,7 @@ async function renderWithRestartOnCacheMissInDev(
             // Regardless of whether we are going to abandon this
             // render we need the unblock runtime b/c it's essential
             // filling caches.
-            initialStageController.abandonRender()
+            initialAbandonController.abort()
             return null
           }
 
@@ -3394,7 +3396,7 @@ async function renderWithRestartOnCacheMissInDev(
           // We won't advance the stage, and thus leave dynamic APIs hanging,
           // because they won't be cached anyway, so it'd be wasted work.
           if (cacheSignal.hasPendingReads()) {
-            initialStageController.abandonRender()
+            initialAbandonController.abort()
             return null
           }
 

filename: packages/next/src/server/app-render/staged-rendering.ts
patch: @@ -25,11 +25,10 @@ export class StagedRenderingController {
   private runtimeStagePromise = createPromiseWithResolvers<void>()
   private dynamicStagePromise = createPromiseWithResolvers<void>()
 
-  private mayAbandon: boolean = false
-
   constructor(
     private abortSignal: AbortSignal | null = null,
-    private hasRuntimePrefetch: boolean
+    private hasRuntimePrefetch: boolean,
+    private abandonController: AbortController | null = null
   ) {
     if (abortSignal) {
       abortSignal.addEventListener(
@@ -50,8 +49,16 @@ export class StagedRenderingController {
         },
         { once: true }
       )
+    }
 
-      this.mayAbandon = true
+    if (abandonController) {
+      abandonController.signal.addEventListener(
+        'abort',
+        () => {
+          this.abandonRender()
+        },
+        { once: true }
+      )
     }
   }
 
@@ -85,11 +92,17 @@ export class StagedRenderingController {
       return
     }
 
-    // If Sync IO occurs during the initial (abandonable) render, we'll retry it,
-    // so we want a slightly different flow.
-    // See the implementation of `abandonRenderImpl` for more explanation.
-    if (this.mayAbandon) {
-      return this.abandonRenderImpl()
+    // If the render has already been abandoned, there's nothing to interrupt.
+    if (this.currentStage === RenderStage.Abandoned) {
+      return
+    }
+
+    // If Sync IO occurs during an abandonable render, we trigger the abandon.
+    // The abandon listener will call abandonRenderImpl which advances through
+    // stages to let caches fill before marking as Abandoned.
+    if (this.abandonController) {
+      this.abandonController.abort()
+      return
     }
 
     // If we're in the final render, we cannot abandon it. We need to advance to the Dynamic stage
@@ -114,7 +127,6 @@ export class StagedRenderingController {
         return
       }
       case RenderStage.Dynamic:
-      case RenderStage.Abandoned:
       default:
     }
   }
@@ -135,17 +147,7 @@ export class StagedRenderingController {
     return this.runtimeStageEndTime
   }
 
-  abandonRender() {
-    if (!this.mayAbandon) {
-      throw new InvariantError(
-        '`abandonRender` called on a stage controller that cannot be abandoned.'
-      )
-    }
-
-    this.abandonRenderImpl()
-  }
-
-  private abandonRenderImpl() {
+  private abandonRender() {
     // In staged rendering, only the initial render is abandonable.
     // We can abandon the initial render if
     //   1. We notice a cache miss, and need to wait for caches to fill