vercel/next.js · #101593
Prerender route handlers that await immediates
packages/next/src/server/route-modules/app-route/module.ts142 + / 86 −
@@ -69,7 +69,15 @@ import { } from '../../app-render/dynamic-rendering' import { ReflectAdapter } from '../../web/spec-extension/adapters/reflect' import { CacheSignal } from '../../app-render/cache-signal'-import { scheduleImmediate } from '../../../lib/scheduler'+import { runInSequentialTasks } from '../../app-render/app-render-render-utils'+import {+ DANGEROUSLY_runPendingImmediatesAfterCurrentTask,+ expectNoPendingImmediates,+ ImmediateTracker,+ runWithNativeImmediateTracking,+ unpatchedSetImmediate,+} from '../../node-environment-extensions/fast-set-immediate.external'+import { createPromiseWithResolvers } from '../../../shared/lib/promise-with-resolvers' import { createServerParamsForRoute } from '../../request/params' import type { AppSegment } from '../../../build/segment-config/app/app-segments' import {@@ -571,27 +579,14 @@ export class AppRouteRouteModule extends RouteModule< : userlandRevalidate if (cacheComponentsEnabled) {- /**- * When we are attempting to statically prerender the GET handler of a route.ts module- * and cacheComponents is on we follow a similar pattern to rendering.- *- * We first run the handler letting caches fill. If something synchronously dynamic occurs- * during this prospective render then we can infer it will happen on every render and we- * just bail out of prerendering.- *- * Next we run the handler again and we check if we get a result back in a microtask.- * Next.js expects the return value to be a Response or a Thenable that resolves to a Response.- * Unfortunately Response's do not allow for accessing the response body synchronously or in- * a microtask so we need to allow one more task to unwrap the response body. This is a slightly- * different semantic than what we have when we render and it means that certain tasks can still- * execute before a prerender completes such as a carefully timed setImmediate.- *- * Functionally though IO should still take longer than the time it takes to unwrap the response body- * so our heuristic of excluding any IO should be preserved.- */+ // The prospective pass fills caches, including reads reached through+ // immediate callbacks. The final pass then runs the handler with fast+ // immediates before checking whether it completed. A returned Response+ // gets a separate native immediate deadline for body consumption. const prospectiveController = new AbortController() let prospectiveRenderIsDynamic = false- const cacheSignal = new CacheSignal(null)+ const immediateTracker = new ImmediateTracker()+ const cacheSignal = new CacheSignal(immediateTracker) let dynamicTracking = createDynamicTrackingState(undefined) // TODO: Route handlers are never resumed, so it's counter-intuitive@@ -634,6 +629,8 @@ export class AppRouteRouteModule extends RouteModule< try { prospectiveResult = this.workUnitAsyncStorage.run( prospectiveRoutePrerenderStore,+ runWithNativeImmediateTracking,+ immediateTracker, handler, request, handlerContext@@ -729,81 +726,140 @@ export class AppRouteRouteModule extends RouteModule< isFallbackUpgradeable: false, }) - let responseHandled = false- res = await new Promise((resolve, reject) => {- scheduleImmediate(async () => {- try {- const result = await (this.workUnitAsyncStorage.run(- finalRoutePrerenderStore,- handler,- request,- handlerContext- ) as Promise<Response>)- if (responseHandled) {- // we already rejected in the followup task- return- } else if (!(result instanceof Response)) {- // This is going to error but we let that happen below- resolve(result)- return- }+ const result: {+ status: 'pending-handler' | 'pending-body' | 'complete' | 'failed'+ value: unknown+ } = { status: 'pending-handler', value: undefined }+ const bodyWindow = createPromiseWithResolvers<void>()+ let bodyDeadline: NodeJS.Immediate | undefined+ let waitForBody = false++ function fail(error: unknown) {+ if (+ result.status === 'pending-handler' ||+ result.status === 'pending-body'+ ) {+ result.status = 'failed'+ result.value = error+ }+ } - responseHandled = true+ function abort() {+ // A handler can catch a synchronous dynamic-access error and return a+ // Response. Preserve that bailout before our own cleanup abort.+ if (finalController.signal.aborted && result.status !== 'failed') {+ result.status = 'failed'+ result.value = createCacheComponentsError(workStore.route)+ }+ finalController.abort()+ } - const init: ResponseInit = {- headers: result.headers,- status: result.status,- statusText: result.statusText,- }+ try {+ await runInSequentialTasks(+ () => {+ void (async () => {+ try {+ const response = await this.workUnitAsyncStorage.run(+ finalRoutePrerenderStore,+ handler,+ request,+ handlerContext+ )+ if (result.status !== 'pending-handler') {+ return+ }+ if (!(response instanceof Response)) {+ // Let finalizeResponse report invalid handler return+ // values.+ result.status = 'complete'+ result.value = response+ return+ } - // There's nothing to buffer, and rebuilding with the empty buffer- // from `arrayBuffer()` would throw for null body statuses (e.g.- // the 204 from an auto-implemented OPTIONS handler).- if (result.body === null) {- resolve(new Response(null, init))- return- }+ const init: ResponseInit = {+ headers: response.headers,+ status: response.status,+ statusText: response.statusText,+ }+ if (response.body === null) {+ result.value = new Response(null, init)+ result.status = 'complete'+ return+ } - let bodyHandled = false- result- .arrayBuffer()- .then((body) => {- if (!bodyHandled) {- bodyHandled = true- resolve(new Response(body, init))+ result.status = 'pending-body'+ const pendingBody = response.arrayBuffer()++ // Node's Blob reader can use a native immediate captured+ // before the timer patch. Queue the deadline after starting+ // consumption so that reader can finish its current turn.+ bodyDeadline = unpatchedSetImmediate(() => {+ try {+ DANGEROUSLY_runPendingImmediatesAfterCurrentTask()+ fail(createCacheComponentsError(workStore.route))+ abort()+ } catch (error) {+ result.status = 'failed'+ result.value = error+ } finally {+ unpatchedSetImmediate(() => {+ try {+ expectNoPendingImmediates()+ } catch (error) {+ result.status = 'failed'+ result.value = error+ }+ bodyWindow.resolve()+ })+ }+ })++ const body = await pendingBody+ if (result.status === 'pending-body') {+ result.value = new Response(body, init)+ result.status = 'complete' }- })- // Ensure anything thrown above rejects instead of becoming an- // unhandled rejection that leaves the prerender hanging.- .catch(reject)- scheduleImmediate(() => {- if (!bodyHandled) {- bodyHandled = true- finalController.abort()- reject(createCacheComponentsError(workStore.route))+ } catch (error) {+ fail(error) }- })- } catch (err) {- reject(err)- }- })- scheduleImmediate(() => {- if (!responseHandled) {- responseHandled = true- finalController.abort()- reject(createCacheComponentsError(workStore.route))+ })()+ },+ () => {+ if (result.status === 'pending-handler') {+ fail(createCacheComponentsError(workStore.route))+ }+ if (+ result.status === 'pending-body' &&+ !finalController.signal.aborted+ ) {+ waitForBody = true+ return+ }++ if (bodyDeadline !== undefined) {+ clearImmediate(bodyDeadline)+ }+ abort() }- })- })- if (finalController.signal.aborted) {- // We aborted from within the execution- throw createCacheComponentsError(workStore.route)- } else {- // We didn't abort during the execution. We can abort now as a matter of semantics- // though at the moment nothing actually consumes this signal so it won't halt any- // inflight work.+ )++ if (waitForBody) {+ await bodyWindow.promise+ }+ } catch (error) {+ fail(error)+ throw error+ } finally {+ if (bodyDeadline !== undefined) {+ clearImmediate(bodyDeadline)+ } finalController.abort() }++ if (result.status === 'failed') {+ throw result.value+ }+ res = result.value } else { prerenderStore = { type: 'prerender-legacy',test/e2e/app-dir/cache-components/app/routes/immediate-cached/route.tsadded21 + / 0 −
@@ -0,0 +1,21 @@+import { setImmediate, setTimeout } from 'node:timers/promises'++import { getSentinelValue } from '../../getSentinelValue'++async function getCachedMessage(message: string) {+ 'use cache'+ await setTimeout(20)+ return message+}++export async function GET() {+ const first = await getCachedMessage('first')++ for (let i = 0; i < 5; i++) {+ await setImmediate()+ }++ const second = await getCachedMessage('second')++ return Response.json({ value: getSentinelValue(), first, second })+}test/e2e/app-dir/cache-components/app/routes/immediate-cookies/route.tsadded14 + / 0 −
@@ -0,0 +1,14 @@+import { setImmediate } from 'node:timers/promises'+import { cookies } from 'next/headers'++import { getSentinelValue } from '../../getSentinelValue'++export async function GET() {+ await setImmediate()+ const cookieStore = await cookies()++ return Response.json({+ value: getSentinelValue(),+ cookie: cookieStore.get('test-immediate')?.value,+ })+}test/e2e/app-dir/cache-components/app/routes/immediate-recursive/route.tsadded16 + / 0 −
@@ -0,0 +1,16 @@+import { getSentinelValue } from '../../getSentinelValue'++export async function GET() {+ await new Promise<void>((resolve) => {+ setImmediate(() => {+ setImmediate(() => {+ setImmediate(resolve)+ })+ })+ })++ return Response.json({+ value: getSentinelValue(),+ message: 'recursive immediate',+ })+}test/e2e/app-dir/cache-components/app/routes/immediate-stream/route.tsadded21 + / 0 −
@@ -0,0 +1,21 @@+import { setImmediate } from 'node:timers/promises'++import { getSentinelValue } from '../../getSentinelValue'++export function GET() {+ const encoder = new TextEncoder()+ let sent = 0++ return new Response(+ new ReadableStream({+ async pull(controller) {+ await setImmediate()+ controller.enqueue(encoder.encode(`${getSentinelValue()}:${sent++}\n`))+ if (sent === 3) {+ controller.close()+ }+ },+ }),+ { headers: { 'content-type': 'text/plain', 'x-test-body': 'immediate' } }+ )+}test/e2e/app-dir/cache-components/app/routes/immediate/route.tsadded12 + / 0 −
@@ -0,0 +1,12 @@+import { setImmediate } from 'node:timers/promises'++import { getSentinelValue } from '../../getSentinelValue'++export async function GET() {+ await setImmediate()++ return Response.json({+ value: getSentinelValue(),+ message: 'immediate',+ })+}test/e2e/app-dir/cache-components/app/routes/static-blob/route.tsadded7 + / 0 −
@@ -0,0 +1,7 @@+import { getSentinelValue } from '../../getSentinelValue'++export function GET() {+ return new Response(new Blob([getSentinelValue()]), {+ headers: { 'content-type': 'text/plain' },+ })+}test/e2e/app-dir/cache-components/cache-components.routes.test.ts62 + / 0 −
@@ -233,6 +233,68 @@ describe('cache-components', () => { } }) + it('should prerender GET route handlers that await setImmediate', async () => {+ const response = await next.fetch('/routes/immediate')++ expect(response.status).toBe(200)+ expect(await response.json()).toEqual({+ value: isNextDev ? 'at runtime' : 'at buildtime',+ message: 'immediate',+ })+ })++ it('should prerender GET route handlers that await recursive immediates', async () => {+ const response = await next.fetch('/routes/immediate-recursive')++ expect(response.status).toBe(200)+ expect(await response.json()).toEqual({+ value: isNextDev ? 'at runtime' : 'at buildtime',+ message: 'recursive immediate',+ })+ })++ it('should prerender response bodies that await immediates between chunks', async () => {+ const response = await next.fetch('/routes/immediate-stream')+ const value = isNextDev ? 'at runtime' : 'at buildtime'++ expect(response.status).toBe(200)+ expect(response.headers.get('content-type')).toBe('text/plain')+ expect(response.headers.get('x-test-body')).toBe('immediate')+ expect(await response.text()).toBe(`${value}:0\n${value}:1\n${value}:2\n`)+ })++ it('should fill caches reached through immediates after another cache resolves', async () => {+ const response = await next.fetch('/routes/immediate-cached')++ expect(response.status).toBe(200)+ expect(await response.json()).toEqual({+ value: isNextDev ? 'at runtime' : 'at buildtime',+ first: 'first',+ second: 'second',+ })+ })++ it('should not prerender request data accessed after an immediate', async () => {+ const response = await next.fetch('/routes/immediate-cookies', {+ headers: { cookie: 'test-immediate=hello' },+ })++ expect(response.status).toBe(200)+ expect(await response.json()).toEqual({+ value: 'at runtime',+ cookie: 'hello',+ })+ })++ it('should prerender an in-memory Blob response body', async () => {+ const response = await next.fetch('/routes/static-blob')++ expect(response.status).toBe(200)+ expect(await response.text()).toBe(+ isNextDev ? 'at runtime' : 'at buildtime'+ )+ })+ it('should not prerender GET route handlers that complete in a new Task', async () => { let str = await next.render('/routes/task', {}) let json = JSON.parse(str)test/production/app-dir/cache-components-route-prerender-errors/app/layout.tsxadded9 + / 0 −
@@ -0,0 +1,9 @@+import type { ReactNode } from 'react'++export default function Root({ children }: { children: ReactNode }) {+ return (+ <html>+ <body>{children}</body>+ </html>+ )+}test/production/app-dir/cache-components-route-prerender-errors/app/page.tsxadded3 + / 0 −
@@ -0,0 +1,3 @@+export default function Page() {+ return <p>hello world</p>+}test/production/app-dir/cache-components-route-prerender-errors/cache-components-route-prerender-errors.test.tsadded25 + / 0 −
@@ -0,0 +1,25 @@+import { nextTestSetup } from 'e2e-utils'++describe('cache-components-route-prerender-errors', () => {+ const { next } = nextTestSetup({+ files: __dirname,+ skipStart: true,+ })++ it.each([+ ['handler-error', 'handler failed after an immediate'],+ ['body-error', 'body failed after an immediate'],+ ['invalid-response', 'Expected a Response object but received'],+ ])('fails the build for %s', async (fixture, message) => {+ await next.patchFile(+ 'app/failure/route.ts',+ await next.readFile(`fixtures/${fixture}.ts`)+ )++ const { exitCode, cliOutput } = await next.build()++ expect(exitCode).toBe(1)+ expect(cliOutput).toContain(message)+ expect(cliOutput).not.toContain('unhandledRejection')+ })+})test/production/app-dir/cache-components-route-prerender-errors/fixtures/body-error.tsadded12 + / 0 −
@@ -0,0 +1,12 @@+import { setImmediate } from 'node:timers/promises'++export function GET() {+ return new Response(+ new ReadableStream({+ async pull(controller) {+ await setImmediate()+ controller.error(new Error('body failed after an immediate'))+ },+ })+ )+}test/production/app-dir/cache-components-route-prerender-errors/fixtures/handler-error.tsadded6 + / 0 −
@@ -0,0 +1,6 @@+import { setImmediate } from 'node:timers/promises'++export async function GET() {+ await setImmediate()+ throw new Error('handler failed after an immediate')+}test/production/app-dir/cache-components-route-prerender-errors/fixtures/invalid-response.tsadded6 + / 0 −
@@ -0,0 +1,6 @@+import { setImmediate } from 'node:timers/promises'++export async function GET() {+ await setImmediate()+ return null as unknown as Response+}test/production/app-dir/cache-components-route-prerender-errors/next.config.tsadded5 + / 0 −
@@ -0,0 +1,5 @@+import type { NextConfig } from 'next'++const nextConfig: NextConfig = { cacheComponents: true }++export default nextConfig