From 521ae9653e7c45ee1690323c86252b82d5740ffa Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 28 Apr 2026 18:40:46 +0200 Subject: [PATCH 1/2] Fix route handler SWR blocking on Node runtime (#93189) In a Node-runtime app route handler, `'use cache'` and fetch-cache stale-while-revalidate block the client response on the full background regeneration. A second request after the cache has gone stale does not return the stale value promptly. It waits the entire regen duration and then returns the freshly regenerated value, as described in issue #93146. The symptom is visible both on self-hosted Node and on Vercel Node. Edge route handlers were unaffected because they route through `edge-route-module-wrapper.ts`, which hands `pendingWaitUntil` directly to `evt.waitUntil` and never awaits it before the response completes. The root cause is independent of the `await ignoredStream.cancel()` change proposed in that issue, and also independent of #92636, which removed a separate blocking await inside `use-cache-wrapper.ts`. Both of those sit inside the `'use cache'` wrapper and affect how the stale entry is returned to the caller. The blocking that remains is in the transport layer and applies equally to any revalidation pushed into `pendingRevalidates` or `pendingRevalidateWrites`, not just `'use cache'`. #74164 migrated pending revalidate handling from the "keep the response stream open until the promise resolves" pattern introduced in #55978 and reinforced in #58744 to a conditional hand-off. If a platform `waitUntil` is available, revalidations run out of band via `ctx.waitUntil`. Otherwise `pipe-readable.ts` keeps `res.end` deferred until they settle, so minimal-mode deployments stay alive long enough for writes to persist. That migration landed correctly for app pages. `app-render.tsx` only assigns `options.waitUntil` in the `else` branch, so once `renderOpts.waitUntil` is present the `pipe-readable.ts` path receives nothing and does not block. The matching code for route handlers was added to `base-server.ts` in the same PR. It declared `let pendingWaitUntil = context.renderOpts.pendingWaitUntil`, cleared it when handing off to `ctx.waitUntil`, and then passed `context.renderOpts.pendingWaitUntil` into `sendResponse`. That is the unmutated property, not the cleared local. The local variable was never read. The hand-off therefore never displaced the pipe-readable await. The same promise was awaited twice. `ctx.waitUntil` registered it redundantly and `pipe-readable.ts` still held `res.end` open for the full revalidation. #80189 copied the block verbatim into `packages/next/src/build/templates/app-route.ts`, carrying the bug forward when response handling moved into the route template. Fixing it is a one-line change. Pass the local `pendingWaitUntil` (which is `undefined` once handed off) into `sendResponse`, so Node route handlers match both app pages and edge route handlers and the original intent of #74164 is restored. The existing `stale-cache-serving/route-handler` test in `app-static.test.ts` did not catch this. It only measured time-to-first-byte against the route start time, and first-byte was already fast pre-fix. Chunks are written before `pipe-readable.ts`'s close handler awaits `waitUntilForEnd`. Only the terminating chunk was delayed. The test is updated to also assert total response time against the route start. Pre-fix that new assertion fails at ~3000ms for the Node route handler variant while the page and edge variants continue to pass, which is exactly the scope of the regression. A new e2e test in `use-cache-swr` exercises the symmetric `'use cache'` path through a route handler fixture. fixes #93146 closes #93177 (incorrect fix) closes #93188 (incorrect fix) --- .../next/src/build/templates/app-route.ts | 2 +- .../e2e/app-dir/app-static/app-static.test.ts | 17 +++++--- .../use-cache-swr/app/delayed-route/route.ts | 19 ++++++++ .../use-cache-swr/use-cache-swr.test.ts | 43 +++++++++++++++++++ 4 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 test/e2e/app-dir/use-cache-swr/app/delayed-route/route.ts diff --git a/packages/next/src/build/templates/app-route.ts b/packages/next/src/build/templates/app-route.ts index 183e4f34832f..9d0599202428 100644 --- a/packages/next/src/build/templates/app-route.ts +++ b/packages/next/src/build/templates/app-route.ts @@ -408,7 +408,7 @@ export async function handler( nodeNextReq, nodeNextRes, response, - context.renderOpts.pendingWaitUntil + pendingWaitUntil ) return null } diff --git a/test/e2e/app-dir/app-static/app-static.test.ts b/test/e2e/app-dir/app-static/app-static.test.ts index 71d200f3f529..984258fa2d8a 100644 --- a/test/e2e/app-dir/app-static/app-static.test.ts +++ b/test/e2e/app-dir/app-static/app-static.test.ts @@ -3342,9 +3342,6 @@ describe('app-dir static/dynamic handling', () => { // Prime the cache. let res = await next.fetch(path) expect(res.status).toBe(200) - - // Consume the cache, the revalidations are completed on the end of the - // stream so we need to wait for that to complete. await res.text() for (let i = 0; i < 6; i++) { @@ -3374,6 +3371,7 @@ describe('app-dir static/dynamic handling', () => { ) } } + const finishedAt = Date.now() const startedResponding = +data.start if (Number.isNaN(startedResponding)) { @@ -3387,12 +3385,17 @@ describe('app-dir static/dynamic handling', () => { ) } - // We just want to ensure the response isn't blocked on revalidating the fetch. - // So we use the start time when route started processing not when we - // send off the response because that includes cold boots of the infra. + // The response must not be blocked on the 3s background revalidation: + // neither the first byte (TTFB) nor the terminating chunk (res.end). + // Using the route-start time excludes cold-boot/infra latency. if (startedStreaming - startedResponding >= 3000) { throw new Error( - `Response #${i} took too long to complete: ${startedStreaming - startedResponding}ms` + `Response #${i} first byte took too long: ${startedStreaming - startedResponding}ms` + ) + } + if (finishedAt - startedResponding >= 3000) { + throw new Error( + `Response #${i} took too long to complete: ${finishedAt - startedResponding}ms` ) } } diff --git a/test/e2e/app-dir/use-cache-swr/app/delayed-route/route.ts b/test/e2e/app-dir/use-cache-swr/app/delayed-route/route.ts new file mode 100644 index 000000000000..56df5ea66fd5 --- /dev/null +++ b/test/e2e/app-dir/use-cache-swr/app/delayed-route/route.ts @@ -0,0 +1,19 @@ +import { cacheLife } from 'next/cache' +import { setTimeout } from 'timers/promises' + +async function getCachedData() { + 'use cache' + + cacheLife('seconds') + + await setTimeout(1000) + + return new Date().toISOString() +} + +export async function GET() { + const cached = await getCachedData() + const dynamic = new Date().toISOString() + + return Response.json({ cached, dynamic }) +} diff --git a/test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts b/test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts index fc73230a58be..0726e2b9e551 100644 --- a/test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts +++ b/test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts @@ -103,6 +103,49 @@ describe('use-cache-swr', () => { expect(duration3).toBeLessThan(1000) }) + it('should serve stale data without blocking on the background regeneration (route handler)', async () => { + // Fetch 1: cold cache. The cached function blocks for ~1s. + const res1 = await next.fetch('/delayed-route') + const { cached: cached1, dynamic: dynamic1 } = await res1.json() + expect(cached1).toBeDateString() + expect(dynamic1).toBeDateString() + + // Wait past the 1s revalidate window (cacheLife('seconds')). + await new Promise((resolve) => setTimeout(resolve, 1200)) + + outputIndex = next.cliOutput.length + + // Fetch 2: stale hit. Should return the stale entry immediately and kick + // off the regeneration in the background, rather than blocking on the 1s + // delay in the cached function. + const start2 = Date.now() + const res2 = await next.fetch('/delayed-route') + const { cached: cached2, dynamic: dynamic2 } = await res2.json() + const duration2 = Date.now() - start2 + + expect(cached2).toBe(cached1) + expect(dynamic2).not.toBe(dynamic1) + expect(duration2).toBeLessThan(1000) + + // Wait for the background regen to finish writing the fresh entry. + await retry(() => { + expect(next.cliOutput.slice(outputIndex)).toMatch( + /PersistentCacheHandler::set/ + ) + }) + + // Fetch 3: should serve the pre-warmed fresh entry from the background + // regen, not a new stale value. + const start3 = Date.now() + const res3 = await next.fetch('/delayed-route') + const { cached: cached3, dynamic: dynamic3 } = await res3.json() + const duration3 = Date.now() - start3 + + expect(cached3).not.toBe(cached1) + expect(dynamic3).not.toBe(dynamic2) + expect(duration3).toBeLessThan(1000) + }) + it('should pass implicit tags to cache handler get() for nested caches during SWR', async () => { const browser = await next.browser('/') await browser.elementById('outer-data').text() From 0ea2845cf943f75440d2f787470fffbcbcabc877 Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:42:19 -0700 Subject: [PATCH 2/2] [ci]: fix release script to not strip newlines (#93302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The process of signing commits with GH API was stripping newlines. This configures that step to not strip newlines so lint doesn't fail. x-ref: https://github.com/vercel/next.js/actions/runs/25007903789/job/73240317094 Stack created with GitHub Stacks CLIGive Feedback 💬 --- lerna.json | 2 +- packages/create-next-app/package.json | 2 +- packages/eslint-config-next/package.json | 2 +- packages/eslint-plugin-internal/package.json | 2 +- packages/eslint-plugin-next/package.json | 2 +- packages/font/package.json | 2 +- packages/next-bundle-analyzer/package.json | 2 +- packages/next-codemod/package.json | 2 +- packages/next-env/package.json | 2 +- packages/next-mdx/package.json | 2 +- packages/next-playwright/package.json | 2 +- packages/next-plugin-storybook/package.json | 2 +- packages/next-polyfill-module/package.json | 2 +- packages/next-polyfill-nomodule/package.json | 2 +- packages/next-routing/package.json | 2 +- packages/next-rspack/package.json | 2 +- packages/next-swc/package.json | 2 +- packages/next/package.json | 2 +- packages/react-refresh-utils/package.json | 2 +- packages/third-parties/package.json | 2 +- pnpm-lock.yaml | 2 +- scripts/release-github-api.js | 1 + 22 files changed, 22 insertions(+), 21 deletions(-) diff --git a/lerna.json b/lerna.json index da599b557614..f79f4e9a5a58 100644 --- a/lerna.json +++ b/lerna.json @@ -16,4 +16,4 @@ } }, "version": "16.3.0-canary.4" -} \ No newline at end of file +} diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index dd4973048e9e..056fbf21319d 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -50,4 +50,4 @@ "engines": { "node": ">=20.9.0" } -} \ No newline at end of file +} diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 21b8993cb1d1..a0edb5d2f6ed 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -60,4 +60,4 @@ "default": "./dist/parser.js" } } -} \ No newline at end of file +} diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index bd83cdf0c0e5..b7d5fa0093d0 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -18,4 +18,4 @@ "eslint": "9.37.0" }, "scripts": {} -} \ No newline at end of file +} diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 8cb7624f5e11..261f15f2755d 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -25,4 +25,4 @@ "types": "tsc --project tsconfig.json --skipLibCheck --declaration --emitDeclarationOnly --esModuleInterop --declarationDir dist", "prepublishOnly": "cd ../../ && turbo run build" } -} \ No newline at end of file +} diff --git a/packages/font/package.json b/packages/font/package.json index 87b730f115e5..f11bd9ea7f2d 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -27,4 +27,4 @@ "fontkit": "2.0.2", "typescript": "6.0.2" } -} \ No newline at end of file +} diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index e8be6e98be98..8032b7703409 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -14,4 +14,4 @@ "scripts": { "pack-for-isolated-tests": "pnpm pack --out ./packed.tgz" } -} \ No newline at end of file +} diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 90e51376e5a3..6f1bfbf0ff94 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -42,4 +42,4 @@ "@types/semver": "7.3.1", "typescript": "6.0.2" } -} \ No newline at end of file +} diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 9d8033cf179f..01a5a3b768b5 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -34,4 +34,4 @@ "dotenv": "16.3.1", "dotenv-expand": "10.0.0" } -} \ No newline at end of file +} diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index bf66b5d1e9ec..bb027703f214 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -25,4 +25,4 @@ "scripts": { "pack-for-isolated-tests": "pnpm pack --out ./packed.tgz" } -} \ No newline at end of file +} diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index aa81fc985dd6..a9888468903c 100644 --- a/packages/next-playwright/package.json +++ b/packages/next-playwright/package.json @@ -29,4 +29,4 @@ "@playwright/test": "1.58.2", "typescript": "6.0.2" } -} \ No newline at end of file +} diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index fb382a6d393e..19df21707a2e 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -6,4 +6,4 @@ "directory": "packages/next-plugin-storybook" }, "scripts": {} -} \ No newline at end of file +} diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 306cc6115dce..c2962c4a8153 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -16,4 +16,4 @@ "devDependencies": { "microbundle": "0.15.0" } -} \ No newline at end of file +} diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 83a423babf2a..03987548d1de 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -19,4 +19,4 @@ "object-assign": "4.1.1", "whatwg-fetch": "3.0.0" } -} \ No newline at end of file +} diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index 30a4c95dd1a6..591cb0cace6c 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -36,4 +36,4 @@ "jest": "^29.5.0", "ts-jest": "^29.1.0" } -} \ No newline at end of file +} diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index 37552b4871b1..a6a3b3128b83 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -12,4 +12,4 @@ "scripts": { "pack-for-isolated-tests": "pnpm pack --out ./packed.tgz" } -} \ No newline at end of file +} diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index fb268bec4358..5e1e60726a25 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -41,4 +41,4 @@ "cross-env": "6.0.3", "wasm-pack": "0.13.1" } -} \ No newline at end of file +} diff --git a/packages/next/package.json b/packages/next/package.json index 23c93bf4f7a6..7601c6fedc03 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -373,4 +373,4 @@ "engines": { "node": ">=20.9.0" } -} \ No newline at end of file +} diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 7ccdac890ebe..140fc51b3cdd 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -29,4 +29,4 @@ "react-refresh": "0.12.0", "webpack": "^4 || ^5" } -} \ No newline at end of file +} diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index bb85cede8a19..008ce607a2c4 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -36,4 +36,4 @@ "next": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0-beta.0", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eac89cdff1aa..e5fa90b87c52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38554,4 +38554,4 @@ snapshots: zwitch@1.0.5: {} - zwitch@2.0.4: {} \ No newline at end of file + zwitch@2.0.4: {} diff --git a/scripts/release-github-api.js b/scripts/release-github-api.js index 3d650a9032db..db4d85e8a2fb 100644 --- a/scripts/release-github-api.js +++ b/scripts/release-github-api.js @@ -143,6 +143,7 @@ async function createBlobForFile(token, commitSha, filePath) { const content = await git(['show', `${commitSha}:${filePath}`], { captureOutput: true, encoding: null, + stripFinalNewline: false, maxBuffer: 1024 * 1024 * 100, }) const blob = await githubRequest(