Repository: vercel/next.js
Pull request: [CC] Fix hanging dynamic promise when abandoning render

Fixes #86662

The bug was introduced in #85747, where we added `RenderStage.Abandoned`. We forgot to update the logic that rejects promises on abort, so if a render was abandoned (i.e. restarted due to a cache miss), then promises waiting for the dynamic stage would hang forever.

This is not generally observable unless the promise is persisted/cached in userspace. Userspace caches are problematic in Cache Components and we discourage their usage, but the test app included here should still work as expected (i.e. without hanging forever while rendering).

Changed files:
filename: packages/next/src/server/app-render/staged-rendering.ts
patch: @@ -40,7 +40,10 @@ export class StagedRenderingController {
             this.runtimeStagePromise.promise.catch(ignoreReject) // avoid unhandled rejections
             this.runtimeStagePromise.reject(reason)
           }
-          if (this.currentStage < RenderStage.Dynamic) {
+          if (
+            this.currentStage < RenderStage.Dynamic ||
+            this.currentStage === RenderStage.Abandoned
+          ) {
             this.dynamicStagePromise.promise.catch(ignoreReject) // avoid unhandled rejections
             this.dynamicStagePromise.reject(reason)
           }

filename: test/development/app-dir/cache-components-reused-promise/app/layout.tsx
patch: @@ -0,0 +1,7 @@
+export default async function Layout({ children }) {
+  return (
+    <html>
+      <body>{children}</body>
+    </html>
+  )
+}

filename: test/development/app-dir/cache-components-reused-promise/app/page.tsx
patch: @@ -0,0 +1,71 @@
+// This repros a bug from https://github.com/vercel/next.js/issues/86662.
+// The bug from occurs if we have:
+// - a cache (which requires warming = a restart in dev)
+// - a dynamic function (blocked until the dynamic stage, like an uncached fetch)
+//   that dedupes concurrent calls. note that that's not quite the same as caching it,
+//   because it gets dropped after finishing.
+
+import { Suspense } from 'react'
+
+export default async function Page() {
+  return (
+    <main>
+      <Suspense fallback={<div>Loading...</div>}>
+        <div id="random">
+          <DynamicRandom />
+        </div>
+      </Suspense>
+    </main>
+  )
+}
+
+async function DynamicRandom() {
+  const [, random] = await Promise.all([
+    // ensure that there's a restart to warm this cache.
+    cached(),
+    // This fetch going to be blocked on the dynamic stage.
+    // That promise should be rejected when restarting.
+    // If it's not rejected (like it wasn't before the fix),
+    // it'll remain hanging, and we'll never render anything.
+    fetchDynamicRandomDeduped(),
+  ])
+  return random
+}
+
+function dedupeConcurrent<T>(func: () => Promise<T>): () => Promise<T> {
+  let pending: Promise<T> | null = null
+  return () => {
+    if (pending !== null) {
+      console.log('dedupe :: re-using pending promise')
+      return pending
+    }
+
+    console.log('dedupe :: starting')
+    const promise = func()
+    pending = promise
+
+    const clearPending = () => {
+      console.log('dedupe :: finished')
+      pending = null
+    }
+    promise.then(clearPending, clearPending)
+
+    return promise
+  }
+}
+
+const fetchDynamicRandomDeduped = dedupeConcurrent(async () => {
+  const res = await fetch(
+    'https://next-data-api-endpoint.vercel.app/api/random'
+  )
+  if (!res.ok) {
+    throw new Error(`request failed with status ${res.status}`)
+  }
+  const text = await res.text()
+  return text
+})
+
+async function cached() {
+  'use cache'
+  await new Promise((resolve) => setTimeout(resolve))
+}

filename: test/development/app-dir/cache-components-reused-promise/cache-components-reused-promise.test.ts
patch: @@ -0,0 +1,12 @@
+import { nextTestSetup } from 'e2e-utils'
+
+describe('cache-components-dev-warmup - reused promise', () => {
+  const { next } = nextTestSetup({
+    files: __dirname,
+  })
+
+  it('aborts dynamic promises when restarting the render', async () => {
+    const browser = await next.browser('/')
+    expect(await browser.elementByCss('#random').text()).toMatch(/\d+\.\d+/)
+  })
+})

filename: test/development/app-dir/cache-components-reused-promise/next.config.ts
patch: @@ -0,0 +1,7 @@
+import type { NextConfig } from 'next'
+
+const nextConfig: NextConfig = {
+  cacheComponents: true,
+}
+
+export default nextConfig