Repository: vercel/next.js
Pull request: Unify runInSequentialTasks across all sequential task work

Stacked on #90065

The older methods were so close in function to runInSequentialTasks we've now unified the interface and use it consistently everywhere.

Changed files:
filename: packages/next/src/server/app-render/app-render-prerender-utils.ts
patch: @@ -1,103 +1,4 @@
 import { InvariantError } from '../../shared/lib/invariant-error'
-import { createAtomicTimerGroup } from './app-render-scheduling'
-import {
-  DANGEROUSLY_runPendingImmediatesAfterCurrentTask,
-  expectNoPendingImmediates,
-} from '../node-environment-extensions/fast-set-immediate.external'
-
-/**
- * This is a utility function to make scheduling sequential tasks that run back to back easier.
- * We schedule on the same queue (setTimeout) at the same time to ensure no other events can sneak in between.
- */
-export function prerenderAndAbortInSequentialTasks<R>(
-  prerender: () => Promise<R>,
-  abort: () => void
-): Promise<R> {
-  if (process.env.NEXT_RUNTIME === 'edge') {
-    throw new InvariantError(
-      '`prerenderAndAbortInSequentialTasks` should not be called in edge runtime.'
-    )
-  } else {
-    return new Promise((resolve, reject) => {
-      const scheduleTimeout = createAtomicTimerGroup()
-
-      let pendingResult: Promise<R>
-      scheduleTimeout(() => {
-        try {
-          DANGEROUSLY_runPendingImmediatesAfterCurrentTask()
-          pendingResult = prerender()
-          pendingResult.catch(() => {})
-        } catch (err) {
-          reject(err)
-        }
-      })
-      scheduleTimeout(() => {
-        try {
-          DANGEROUSLY_runPendingImmediatesAfterCurrentTask()
-          abort()
-        } catch (err) {
-          reject(err)
-        }
-      })
-      scheduleTimeout(() => {
-        try {
-          expectNoPendingImmediates()
-          resolve(pendingResult)
-        } catch (err) {
-          reject(err)
-        }
-      })
-    })
-  }
-}
-
-/**
- * Like `prerenderAndAbortInSequentialTasks`, but with another task between `prerender` and `abort`,
- * which allows us to move a part of the render into a separate task.
- */
-export function prerenderAndAbortInSequentialTasksWithStages<R>(
-  prerender: () => Promise<R>,
-  advanceStage: () => void,
-  abort: () => void
-): Promise<R> {
-  if (process.env.NEXT_RUNTIME === 'edge') {
-    throw new InvariantError(
-      '`prerenderAndAbortInSequentialTasksWithStages` should not be called in edge runtime.'
-    )
-  } else {
-    return new Promise((resolve, reject) => {
-      const scheduleTimeout = createAtomicTimerGroup()
-
-      let pendingResult: Promise<R>
-      scheduleTimeout(() => {
-        try {
-          DANGEROUSLY_runPendingImmediatesAfterCurrentTask()
-          pendingResult = prerender()
-          pendingResult.catch(() => {})
-        } catch (err) {
-          reject(err)
-        }
-      })
-      scheduleTimeout(() => {
-        try {
-          DANGEROUSLY_runPendingImmediatesAfterCurrentTask()
-          advanceStage()
-        } catch (err) {
-          reject(err)
-        }
-      })
-      scheduleTimeout(() => {
-        try {
-          expectNoPendingImmediates()
-          abort()
-          resolve(pendingResult)
-        } catch (err) {
-          reject(err)
-        }
-      })
-    })
-  }
-}
 
 // React's RSC prerender function will emit an incomplete flight stream when using `prerender`. If the connection
 // closes then whatever hanging chunks exist will be errored. This is because prerender (an experimental feature)

filename: packages/next/src/server/app-render/app-render-render-utils.ts
patch: @@ -4,45 +4,9 @@ import {
   DANGEROUSLY_runPendingImmediatesAfterCurrentTask,
   expectNoPendingImmediates,
 } from '../node-environment-extensions/fast-set-immediate.external'
+import { isThenable } from '../../shared/lib/is-thenable'
 
-/**
- * This is a utility function to make scheduling sequential tasks that run back to back easier.
- * We schedule on the same queue (setTimeout) at the same time to ensure no other events can sneak in between.
- */
-export function scheduleInSequentialTasks<R>(
-  render: () => R | Promise<R>,
-  followup: () => void
-): Promise<R> {
-  if (process.env.NEXT_RUNTIME === 'edge') {
-    throw new InvariantError(
-      '`scheduleInSequentialTasks` should not be called in edge runtime.'
-    )
-  } else {
-    return new Promise((resolve, reject) => {
-      const scheduleTimeout = createAtomicTimerGroup()
-
-      let pendingResult: R | Promise<R>
-      scheduleTimeout(() => {
-        try {
-          DANGEROUSLY_runPendingImmediatesAfterCurrentTask()
-          pendingResult = render()
-        } catch (err) {
-          reject(err)
-        }
-      })
-
-      scheduleTimeout(() => {
-        try {
-          expectNoPendingImmediates()
-          followup()
-          resolve(pendingResult)
-        } catch (err) {
-          reject(err)
-        }
-      })
-    })
-  }
-}
+function noop() {}
 
 /**
  * This is a utility function to make scheduling sequential tasks that run back to back easier.
@@ -54,7 +18,7 @@ export function scheduleInSequentialTasks<R>(
 export function runInSequentialTasks<R>(
   first: () => R,
   ...rest: Array<() => void>
-): Promise<R> {
+): Promise<Awaited<R>> {
   if (process.env.NEXT_RUNTIME === 'edge') {
     throw new InvariantError(
       '`runInSequentialTasks` should not be called in edge runtime.'
@@ -70,6 +34,14 @@ export function runInSequentialTasks<R>(
           try {
             DANGEROUSLY_runPendingImmediatesAfterCurrentTask()
             result = first()
+            // If the first function returns a thenable, suppress unhandled
+            // rejections. A later task in the sequence (e.g. an abort) may
+            // cause the promise to reject, and we don't want that to surface
+            // as an unhandled rejection — the caller will observe the
+            // rejection when they await the returned promise.
+            if (isThenable(result)) {
+              result.then(noop, noop)
+            }
           } catch (err) {
             for (let i = 1; i < ids.length; i++) {
               clearTimeout(ids[i])
@@ -104,7 +76,7 @@ export function runInSequentialTasks<R>(
         scheduleTimeout(() => {
           try {
             expectNoPendingImmediates()
-            resolve(result)
+            resolve(result as Awaited<R>)
           } catch (err) {
             reject(err)
           }

filename: packages/next/src/server/app-render/app-render.tsx
patch: @@ -163,24 +163,17 @@ import { getRevalidateReason } from '../instrumentation/utils'
 import { PAGE_SEGMENT_KEY } from '../../shared/lib/segment'
 import type { OpaqueFallbackRouteParams } from '../request/fallback-params'
 import {
-  prerenderAndAbortInSequentialTasksWithStages,
   processPrelude,
-} from './app-render-prerender-utils'
-import {
   type ReactServerPrerenderResult,
   ReactServerResult,
   createReactServerPrerenderResult,
   createReactServerPrerenderResultFromRender,
-  prerenderAndAbortInSequentialTasks,
 } from './app-render-prerender-utils'
 import {
   Phase,
   printDebugThrownValueForProspectiveRender,
 } from './prospective-render-utils'
-import {
-  runInSequentialTasks,
-  scheduleInSequentialTasks,
-} from './app-render-render-utils'
+import { runInSequentialTasks } from './app-render-render-utils'
 import { waitAtLeastOneReactRenderTask } from '../../lib/scheduler'
 import {
   getHmrRefreshHash,
@@ -800,15 +793,19 @@ async function stagedRenderToReadableStreamWithoutCachesInDev(
   const { clientModules } = getClientReferenceManifest()
   const rscPayload = await getPayload(requestStore)
 
-  return await workUnitAsyncStorage.run(
-    requestStore,
-    scheduleInSequentialTasks,
+  return await runInSequentialTasks(
     () => {
       stageController.advanceStage(RenderStage.Static)
-      return renderToReadableStream(rscPayload, clientModules, {
-        ...options,
-        environmentName,
-      })
+      return workUnitAsyncStorage.run(
+        requestStore,
+        renderToReadableStream,
+        rscPayload,
+        clientModules,
+        {
+          ...options,
+          environmentName,
+        }
+      )
     },
     () => {
       stageController.advanceStage(RenderStage.Dynamic)
@@ -1390,7 +1387,7 @@ async function finalRuntimeServerPrerender(
   )
 
   let prerenderIsPending = true
-  const result = await prerenderAndAbortInSequentialTasksWithStages(
+  const result = await runInSequentialTasks(
     async () => {
       // Static stage
       const prerenderResult = await workUnitAsyncStorage.run(
@@ -3328,25 +3325,27 @@ async function renderWithRestartOnCacheMissInDev(
   // where sync IO does not cause aborts, so it's okay if it happens before render.
   const initialRscPayload = await getPayload(requestStore)
 
-  const initialStreamResult = await workUnitAsyncStorage.run(
-    requestStore,
-    runInSequentialTasks,
+  const initialStreamResult = await runInSequentialTasks(
     () => {
       initialStageController.advanceStage(RenderStage.Static)
       startTime = performance.now() + performance.timeOrigin
 
-      const streamPair = ComponentMod.renderToReadableStream(
-        initialRscPayload,
-        clientModules,
-        {
-          onError,
-          environmentName,
-          startTime,
-          filterStackFrame,
-          debugChannel: debugChannel?.serverSide,
-          signal: initialReactController.signal,
-        }
-      ).tee()
+      const streamPair = workUnitAsyncStorage
+        .run(
+          requestStore,
+          ComponentMod.renderToReadableStream,
+          initialRscPayload,
+          clientModules,
+          {
+            onError,
+            environmentName,
+            startTime,
+            filterStackFrame,
+            debugChannel: debugChannel?.serverSide,
+            signal: initialReactController.signal,
+          }
+        )
+        .tee()
 
       // If we abort the render, we want to reject the stage-dependent promises as well.
       // Note that we want to install this listener after the render is started
@@ -3462,24 +3461,26 @@ async function renderWithRestartOnCacheMissInDev(
   // where sync IO does not cause aborts, so it's okay if it happens before render.
   const finalRscPayload = await getPayload(requestStore)
 
-  const finalStreamResult = await workUnitAsyncStorage.run(
-    requestStore,
-    runInSequentialTasks,
+  const finalStreamResult = await runInSequentialTasks(
     () => {
       finalStageController.advanceStage(RenderStage.Static)
       startTime = performance.now() + performance.timeOrigin
 
-      const streamPair = ComponentMod.renderToReadableStream(
-        finalRscPayload,
-        clientModules,
-        {
-          onError,
-          environmentName,
-          startTime,
-          filterStackFrame,
-          debugChannel: debugChannel?.serverSide,
-        }
-      ).tee()
+      const streamPair = workUnitAsyncStorage
+        .run(
+          requestStore,
+          ComponentMod.renderToReadableStream,
+          finalRscPayload,
+          clientModules,
+          {
+            onError,
+            environmentName,
+            startTime,
+            filterStackFrame,
+            debugChannel: debugChannel?.serverSide,
+          }
+        )
+        .tee()
 
       return {
         stream: streamPair[0],
@@ -4059,71 +4060,70 @@ async function validateStagedShell(
     require('react-dom/static') as typeof import('react-dom/static')
   ).prerender
   try {
-    let { prelude: unprocessedPrelude } =
-      await prerenderAndAbortInSequentialTasks(
-        () => {
-          const pendingFinalClientResult = workUnitAsyncStorage.run(
-            finalClientPrerenderStore,
-            prerender,
-            // eslint-disable-next-line @next/internal/no-ambiguous-jsx -- React Client
-            <App
-              reactServerStream={serverStream}
-              reactDebugStream={debugChannelClient}
-              debugEndTime={debugEndTime}
-              preinitScripts={preinitScripts}
-              ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
-              nonce={nonce}
-              images={ctx.renderOpts.images}
-            />,
-            {
-              signal: clientReactController.signal,
-              onError: (err: unknown, errorInfo: ErrorInfo) => {
-                if (
-                  isPrerenderInterruptedError(err) ||
-                  clientReactController.signal.aborted
-                ) {
-                  const componentStack = errorInfo.componentStack
-                  if (typeof componentStack === 'string') {
-                    trackDynamicHole(
-                      workStore,
-                      componentStack,
-                      dynamicValidation,
-                      clientDynamicTracking
-                    )
-                  }
-                  return
-                }
-
-                if (isReactLargeShellError(err)) {
-                  // TODO: Aggregate
-                  console.error(err)
-                  return undefined
+    let { prelude: unprocessedPrelude } = await runInSequentialTasks(
+      () => {
+        const pendingFinalClientResult = workUnitAsyncStorage.run(
+          finalClientPrerenderStore,
+          prerender,
+          // eslint-disable-next-line @next/internal/no-ambiguous-jsx -- React Client
+          <App
+            reactServerStream={serverStream}
+            reactDebugStream={debugChannelClient}
+            debugEndTime={debugEndTime}
+            preinitScripts={preinitScripts}
+            ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
+            nonce={nonce}
+            images={ctx.renderOpts.images}
+          />,
+          {
+            signal: clientReactController.signal,
+            onError: (err: unknown, errorInfo: ErrorInfo) => {
+              if (
+                isPrerenderInterruptedError(err) ||
+                clientReactController.signal.aborted
+              ) {
+                const componentStack = errorInfo.componentStack
+                if (typeof componentStack === 'string') {
+                  trackDynamicHole(
+                    workStore,
+                    componentStack,
+                    dynamicValidation,
+                    clientDynamicTracking
+                  )
                 }
+                return
+              }
 
-                return getDigestForWellKnownError(err)
-              },
-              // We don't need bootstrap scripts in this prerender
-              // bootstrapScripts: [bootstrapScript],
-            }
-          )
+              if (isReactLargeShellError(err)) {
+                // TODO: Aggregate
+                console.error(err)
+                return undefined
+              }
 
-          // The listener to abort our own render controller must be added after
-          // React has added its listener, to ensure that pending I/O is not
-          // aborted/rejected too early.
-          clientReactController.signal.addEventListener(
-            'abort',
-            () => {
-              clientRenderController.abort()
+              return getDigestForWellKnownError(err)
             },
-            { once: true }
-          )
+            // We don't need bootstrap scripts in this prerender
+            // bootstrapScripts: [bootstrapScript],
+          }
+        )
 
-          return pendingFinalClientResult
-        },
-        () => {
-          clientReactController.abort()
-        }
-      )
+        // The listener to abort our own render controller must be added after
+        // React has added its listener, to ensure that pending I/O is not
+        // aborted/rejected too early.
+        clientReactController.signal.addEventListener(
+          'abort',
+          () => {
+            clientRenderController.abort()
+          },
+          { once: true }
+        )
+
+        return pendingFinalClientResult
+      },
+      () => {
+        clientReactController.abort()
+      }
+    )
 
     const { preludeIsEmpty } = await processPrelude(unprocessedPrelude)
     return getStaticShellDisallowedDynamicReasons(
@@ -4368,83 +4368,82 @@ async function validateInstantConfigNavigation(
     require('react-dom/static') as typeof import('react-dom/static')
   ).prerender
   try {
-    let { prelude: unprocessedPrelude } =
-      await prerenderAndAbortInSequentialTasks(
-        () => {
-          const pendingFinalClientResult = workUnitAsyncStorage.run(
-            finalClientPrerenderStore,
-            prerender,
-            // eslint-disable-next-line @next/internal/no-ambiguous-jsx -- React Client
-            <App
-              reactServerStream={serverStream}
-              reactDebugStream={debugStream ?? undefined}
-              // Debug info is already filtered when constructing the combined payload.
-              debugEndTime={undefined}
-              preinitScripts={preinitScripts}
-              ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
-              nonce={nonce}
-              images={ctx.renderOpts.images}
-            />,
-            {
-              signal: clientReactController.signal,
-              onError: (err: unknown, errorInfo: ErrorInfo) => {
-                if (
-                  isPrerenderInterruptedError(err) ||
-                  clientReactController.signal.aborted
-                ) {
-                  const componentStack = errorInfo.componentStack
-                  if (typeof componentStack === 'string') {
-                    trackDynamicHoleInNavigation(
-                      workStore,
-                      componentStack,
-                      dynamicValidation,
-                      clientDynamicTracking,
-                      dynamicHoleKind,
-                      boundaryState
-                    )
-                  }
-                  return
-                } else if (!clientReactController.signal.aborted) {
-                  const componentStack = errorInfo.componentStack
-                  if (typeof componentStack === 'string') {
-                    trackThrownErrorInNavigation(
-                      dynamicValidation,
-                      err,
-                      componentStack
-                    )
-                  }
+    let { prelude: unprocessedPrelude } = await runInSequentialTasks(
+      () => {
+        const pendingFinalClientResult = workUnitAsyncStorage.run(
+          finalClientPrerenderStore,
+          prerender,
+          // eslint-disable-next-line @next/internal/no-ambiguous-jsx -- React Client
+          <App
+            reactServerStream={serverStream}
+            reactDebugStream={debugStream ?? undefined}
+            // Debug info is already filtered when constructing the combined payload.
+            debugEndTime={undefined}
+            preinitScripts={preinitScripts}
+            ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
+            nonce={nonce}
+            images={ctx.renderOpts.images}
+          />,
+          {
+            signal: clientReactController.signal,
+            onError: (err: unknown, errorInfo: ErrorInfo) => {
+              if (
+                isPrerenderInterruptedError(err) ||
+                clientReactController.signal.aborted
+              ) {
+                const componentStack = errorInfo.componentStack
+                if (typeof componentStack === 'string') {
+                  trackDynamicHoleInNavigation(
+                    workStore,
+                    componentStack,
+                    dynamicValidation,
+                    clientDynamicTracking,
+                    dynamicHoleKind,
+                    boundaryState
+                  )
                 }
-
-                if (isReactLargeShellError(err)) {
-                  // TODO: Aggregate
-                  console.error(err)
-                  return undefined
+                return
+              } else if (!clientReactController.signal.aborted) {
+                const componentStack = errorInfo.componentStack
+                if (typeof componentStack === 'string') {
+                  trackThrownErrorInNavigation(
+                    dynamicValidation,
+                    err,
+                    componentStack
+                  )
                 }
+              }
 
-                return getDigestForWellKnownError(err)
-              },
-              // We don't need bootstrap scripts in this prerender
-              // bootstrapScripts: [bootstrapScript],
-            }
-          )
+              if (isReactLargeShellError(err)) {
+                // TODO: Aggregate
+                console.error(err)
+                return undefined
+              }
 
-          // The listener to abort our own render controller must be added after
-          // React has added its listener, to ensure that pending I/O is not
-          // aborted/rejected too early.
-          clientReactController.signal.addEventListener(
-            'abort',
-            () => {
-              clientRenderController.abort()
+              return getDigestForWellKnownError(err)
             },
-            { once: true }
-          )
+            // We don't need bootstrap scripts in this prerender
+            // bootstrapScripts: [bootstrapScript],
+          }
+        )
 
-          return pendingFinalClientResult
-        },
-        () => {
-          clientReactController.abort()
-        }
-      )
+        // The listener to abort our own render controller must be added after
+        // React has added its listener, to ensure that pending I/O is not
+        // aborted/rejected too early.
+        clientReactController.signal.addEventListener(
+          'abort',
+          () => {
+            clientRenderController.abort()
+          },
+          { once: true }
+        )
+
+        return pendingFinalClientResult
+      },
+      () => {
+        clientReactController.abort()
+      }
+    )
 
     const { preludeIsEmpty } = await processPrelude(unprocessedPrelude)
 
@@ -5049,7 +5048,7 @@ async function prerenderToStream(
       let prerenderIsPending = true
       const reactServerResult = (reactServerPrerenderResult =
         await createReactServerPrerenderResult(
-          prerenderAndAbortInSequentialTasks(
+          runInSequentialTasks(
             async () => {
               const pendingPrerenderResult = workUnitAsyncStorage.run(
                 // The store to scope
@@ -5152,7 +5151,7 @@ async function prerenderToStream(
         require('react-dom/static') as typeof import('react-dom/static')
       ).prerender
       let { prelude: unprocessedPrelude, postponed } =
-        await prerenderAndAbortInSequentialTasks(
+        await runInSequentialTasks(
           () => {
             const pendingFinalClientResult = workUnitAsyncStorage.run(
               finalClientPrerenderStore,

filename: packages/next/src/server/app-render/instant-validation/instant-validation.tsx
patch: @@ -9,10 +9,7 @@ import type { VaryParamsThenable } from '../../../shared/lib/segment-cache/vary-
 import { InvariantError } from '../../../shared/lib/invariant-error'
 import { RenderStage } from '../staged-rendering'
 import { getServerModuleMap } from '../manifests-singleton'
-import {
-  runInSequentialTasks,
-  scheduleInSequentialTasks,
-} from '../app-render-render-utils'
+import { runInSequentialTasks } from '../app-render-render-utils'
 import { workAsyncStorage } from '../work-async-storage.external'
 import {
   Phase,
@@ -684,9 +681,9 @@ export async function createCombinedPayloadStream(
   const debugChunks: Uint8Array[] | null = isDebugChannelEnabled ? [] : null
   const debugChannel = isDebugChannelEnabled ? createDebugChannel() : null
 
-  let streamFinished: Promise<any> = null!
+  let streamFinished: Promise<any>
 
-  await scheduleInSequentialTasks(
+  await runInSequentialTasks(
     () => {
       const stream = renderToReadableStream(
         payload,
@@ -742,7 +739,7 @@ export async function createCombinedPayloadStream(
     }
   )
 
-  await streamFinished
+  await streamFinished!
 
   return {
     stream: createNodeStreamWithLateRelease(