vercel/next.js · #101593

Prerender route handlers that await immediates

unstubbable · merged Nov 11, 202615 files · 361 + / 86 −
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