From cbc4c8cac22880777fa39eef20df146e691c4016 Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Mon, 1 Dec 2025 13:44:37 +0100 Subject: [PATCH 1/2] [CC] Fix hanging dynamic promise when abandoning render --- packages/next/src/server/app-render/staged-rendering.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/next/src/server/app-render/staged-rendering.ts b/packages/next/src/server/app-render/staged-rendering.ts index 67d00e741e3fe..6b5b0da763490 100644 --- a/packages/next/src/server/app-render/staged-rendering.ts +++ b/packages/next/src/server/app-render/staged-rendering.ts @@ -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) } From f00e3792d7bd8da60aaff8dd02b90635be430d56 Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Tue, 2 Dec 2025 14:07:47 +0100 Subject: [PATCH 2/2] tests --- .../app/layout.tsx | 7 ++ .../app/page.tsx | 71 +++++++++++++++++++ .../cache-components-reused-promise.test.ts | 12 ++++ .../next.config.ts | 7 ++ 4 files changed, 97 insertions(+) create mode 100644 test/development/app-dir/cache-components-reused-promise/app/layout.tsx create mode 100644 test/development/app-dir/cache-components-reused-promise/app/page.tsx create mode 100644 test/development/app-dir/cache-components-reused-promise/cache-components-reused-promise.test.ts create mode 100644 test/development/app-dir/cache-components-reused-promise/next.config.ts diff --git a/test/development/app-dir/cache-components-reused-promise/app/layout.tsx b/test/development/app-dir/cache-components-reused-promise/app/layout.tsx new file mode 100644 index 0000000000000..153268847e55a --- /dev/null +++ b/test/development/app-dir/cache-components-reused-promise/app/layout.tsx @@ -0,0 +1,7 @@ +export default async function Layout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/development/app-dir/cache-components-reused-promise/app/page.tsx b/test/development/app-dir/cache-components-reused-promise/app/page.tsx new file mode 100644 index 0000000000000..3ccb03732a271 --- /dev/null +++ b/test/development/app-dir/cache-components-reused-promise/app/page.tsx @@ -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 ( +
+ Loading...}> +
+ +
+
+
+ ) +} + +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(func: () => Promise): () => Promise { + let pending: Promise | 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)) +} diff --git a/test/development/app-dir/cache-components-reused-promise/cache-components-reused-promise.test.ts b/test/development/app-dir/cache-components-reused-promise/cache-components-reused-promise.test.ts new file mode 100644 index 0000000000000..b1ec296a05c2d --- /dev/null +++ b/test/development/app-dir/cache-components-reused-promise/cache-components-reused-promise.test.ts @@ -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+/) + }) +}) diff --git a/test/development/app-dir/cache-components-reused-promise/next.config.ts b/test/development/app-dir/cache-components-reused-promise/next.config.ts new file mode 100644 index 0000000000000..fa33c7c54f24c --- /dev/null +++ b/test/development/app-dir/cache-components-reused-promise/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + cacheComponents: true, +} + +export default nextConfig