Skip to content

Commit

Permalink
Add support for dynamicIO experiment to route.ts modules (vercel#…
Browse files Browse the repository at this point in the history
…70208)

`dyanmicIO` enables a semantic where any uncached IO will be excluded
from prerenders. Before PPR this means any uncached IO would bail out of
static generation at build time and during revalidates.

Route handlers have a similar ability to be statically generated at
build and revalidate time however we recently changed the behavior to
only be enabled if you explicitly opt into this using an API like
`export const revalidate` or `export const dynamic = "force-static"`.
With dynamic IO though we don't really need to be this restrictive.

If you use `unstable_cache` or `fetch(..., { cache: 'force-cache' })`
you are opting into caching so when dynamicIO is on we can infer that
the GET handler is valid to cache as long as there is no uncached IO. So
in this commit we restore static generation when `dynamicIO` is enabled.
  • Loading branch information
gnoff committed Sep 18, 2024
1 parent ac4a1c6 commit b6ab2cd
Show file tree
Hide file tree
Showing 17 changed files with 694 additions and 5 deletions.
10 changes: 9 additions & 1 deletion packages/next/src/export/routes/app-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,15 @@ export async function exportAppRoute(
const normalizedPage = normalizeAppPath(page)
const isMetadataRoute = isMetadataRouteFile(normalizedPage, [], false)

if (!isStaticGenEnabled(userland) && !isMetadataRoute) {
if (
!isStaticGenEnabled(userland) &&
!isMetadataRoute &&
// We don't disable static gen when dynamicIO is enabled because we
// expect that anything dynamic in the GET handler will make it dynamic
// and thus avoid the cache surprises that led to us removing static gen
// unless specifically opted into
experimental.dynamicIO !== true
) {
return { revalidate: 0 }
}

Expand Down
189 changes: 185 additions & 4 deletions packages/next/src/server/route-modules/app-route/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,27 @@ import {
staticGenerationAsyncStorage,
type StaticGenerationStore,
} from '../../../client/components/static-generation-async-storage.external'
import { prerenderAsyncStorage } from '../../app-render/prerender-async-storage.external'
import {
prerenderAsyncStorage,
type PrerenderStore,
} from '../../app-render/prerender-async-storage.external'
import { actionAsyncStorage } from '../../../client/components/action-async-storage.external'
import * as sharedModules from './shared-modules'
import { getIsServerAction } from '../../lib/server-action-request-meta'
import { RequestCookies } from 'next/dist/compiled/@edge-runtime/cookies'
import { cleanURL } from './helpers/clean-url'
import { StaticGenBailoutError } from '../../../client/components/static-generation-bailout'
import { isStaticGenEnabled } from './helpers/is-static-gen-enabled'
import { trackDynamicDataAccessed } from '../../app-render/dynamic-rendering'
import {
trackDynamicDataAccessed,
createDynamicTrackingState,
getFirstDynamicReason,
isPrerenderInterruptedError,
} from '../../app-render/dynamic-rendering'
import { ReflectAdapter } from '../../web/spec-extension/adapters/reflect'
import type { RenderOptsPartial } from '../../app-render/types'
import { CacheSignal } from '../../app-render/cache-signal'
import { scheduleImmediate } from '../../../lib/scheduler'

/**
* The AppRouteModule is the type of the module exported by the bundled App
Expand Down Expand Up @@ -275,6 +285,8 @@ export class AppRouteRouteModule extends RouteModule<
},
}

const dynamicIOEnabled = !!context.renderOpts.experimental?.dynamicIO

// Get the context for the static generation.
const staticGenerationContext: StaticGenerationContext = {
// App Routes don't support unknown route params.
Expand Down Expand Up @@ -388,11 +400,174 @@ export class AppRouteRouteModule extends RouteModule<
requestAsyncStorage: this.requestAsyncStorage,
prerenderAsyncStorage: this.prerenderAsyncStorage,
})
const res = await handler(request, {

const handlerContext = {
params: context.params
? parsedUrlQueryToParams(context.params)
: undefined,
})
}

let res
if (isStaticGeneration && dynamicIOEnabled) {
/**
* When we are attempting to statically prerender the GET handler of a route.ts module
* and dynamicIO 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 acessing 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.
*/
let prospectiveRenderIsDynamic = false
const cacheSignal = new CacheSignal()
let dynamicTracking =
createDynamicTrackingState(undefined)
const prospectiveRoutePrerenderStore: PrerenderStore = {
cacheSignal,
// During prospective render we don't use a controller
// because we need to let all caches fill.
controller: null,
dynamicTracking,
}
let prospectiveResult
try {
prospectiveResult = this.prerenderAsyncStorage.run(
prospectiveRoutePrerenderStore,
handler,
request,
handlerContext
)
} catch (err) {
if (isPrerenderInterruptedError(err)) {
// the route handler called an API which is always dynamic
// there is no need to try again
prospectiveRenderIsDynamic = true
}
}
if (
typeof prospectiveResult === 'object' &&
prospectiveResult !== null &&
typeof (prospectiveResult as any).then === 'function'
) {
// The handler returned a Thenable. We'll listen for rejections to determine
// if the route is erroring for dynamic reasons.
;(prospectiveResult as any as Promise<unknown>).then(
() => {},
(err) => {
if (isPrerenderInterruptedError(err)) {
// the route handler called an API which is always dynamic
// there is no need to try again
prospectiveRenderIsDynamic = true
}
}
)
}
await cacheSignal.cacheReady()

if (prospectiveRenderIsDynamic) {
// the route handler called an API which is always dynamic
// there is no need to try again
const dynamicReason =
getFirstDynamicReason(dynamicTracking)
if (dynamicReason) {
throw new DynamicServerError(
`Route ${staticGenerationStore.route} couldn't be rendered statically because it used \`${dynamicReason}\`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error`
)
} else {
console.error(
'Expected Next.js to keep track of reason for opting out of static rendering but one was not found. This is a bug in Next.js'
)
throw new DynamicServerError(
`Route ${staticGenerationStore.route} couldn't be rendered statically because it used a dynamic API. See more info here: https://nextjs.org/docs/messages/dynamic-server-error`
)
}
}

// TODO start passing this controller to the route handler. We should expose
// it so the handler to abort inflight requests and other operations if we abort
// the prerender.
const controller = new AbortController()
dynamicTracking = createDynamicTrackingState(undefined)

const finalRoutePrerenderStore: PrerenderStore = {
cacheSignal: null,
controller,
dynamicTracking,
}

let responseHandled = false
res = await new Promise((resolve, reject) => {
scheduleImmediate(async () => {
try {
const result =
await (this.prerenderAsyncStorage.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
}

responseHandled = true

let bodyHandled = false
result.arrayBuffer().then((body) => {
if (!bodyHandled) {
bodyHandled = true

resolve(
new Response(body, {
headers: result.headers,
status: result.status,
statusText: result.statusText,
})
)
}
}, reject)
scheduleImmediate(() => {
if (!bodyHandled) {
bodyHandled = true
controller.abort()
reject(
createDynamicIOError(
staticGenerationStore.route
)
)
}
})
} catch (err) {
reject(err)
}
})
scheduleImmediate(() => {
if (!responseHandled) {
responseHandled = true
controller.abort()
reject(
createDynamicIOError(staticGenerationStore.route)
)
}
})
})
} else {
res = await handler(request, handlerContext)
}
if (!(res instanceof Response)) {
throw new Error(
`No response is returned from route handler '${this.resolvedPagePath}'. Ensure you return a \`Response\` or a \`NextResponse\` in all branches of your handler.`
Expand Down Expand Up @@ -830,3 +1005,9 @@ const requireStaticNextUrlHandlers = {
}
},
}

function createDynamicIOError(route: string) {
return new DynamicServerError(
`Route ${route} couldn't be rendered statically because it used IO that was not cached. See more info here: https://nextjs.org/docs/messages/dynamic-io`
)
}
16 changes: 16 additions & 0 deletions test/e2e/app-dir/dynamic-io/app/routes/dynamic-cookies/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { NextRequest } from 'next/server'

import { cookies } from 'next/headers'

import { getSentinelValue } from '../../getSentinelValue'

export async function GET(request: NextRequest, { params }: { params: {} }) {
const sentinel = cookies().get('x-sentinel')
return new Response(
JSON.stringify({
value: getSentinelValue(),
type: 'cookies',
'x-sentinel': sentinel,
})
)
}
16 changes: 16 additions & 0 deletions test/e2e/app-dir/dynamic-io/app/routes/dynamic-headers/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { NextRequest } from 'next/server'

import { headers } from 'next/headers'

import { getSentinelValue } from '../../getSentinelValue'

export async function GET(request: NextRequest, { params }: { params: {} }) {
const sentinel = headers().get('x-sentinel')
return new Response(
JSON.stringify({
value: getSentinelValue(),
type: 'headers',
'x-sentinel': sentinel,
})
)
}
27 changes: 27 additions & 0 deletions test/e2e/app-dir/dynamic-io/app/routes/dynamic-stream/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { NextRequest } from 'next/server'

import { getSentinelValue } from '../../getSentinelValue'

export async function GET(request: NextRequest, { params }: { params: {} }) {
const result = JSON.stringify({
value: getSentinelValue(),
message: 'dynamic stream',
})
const part1 = result.slice(0, result.length / 2)
const part2 = result.slice(result.length / 2)

const encoder = new TextEncoder()
const chunks = [encoder.encode(part1), encoder.encode(part2)]

let sent = 0
const stream = new ReadableStream({
async pull(controller) {
controller.enqueue(chunks[sent++])
await new Promise((r) => setTimeout(r, 1))
if (sent === chunks.length) {
controller.close()
}
},
})
return new Response(stream)
}
13 changes: 13 additions & 0 deletions test/e2e/app-dir/dynamic-io/app/routes/dynamic-url/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { NextRequest } from 'next/server'

import { getSentinelValue } from '../../getSentinelValue'

export async function GET(request: NextRequest, { params }: { params: {} }) {
const search = request.nextUrl.search
return new Response(
JSON.stringify({
value: getSentinelValue(),
search,
})
)
}
23 changes: 23 additions & 0 deletions test/e2e/app-dir/dynamic-io/app/routes/fetch-cached/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { NextRequest } from 'next/server'

import { getSentinelValue } from '../../getSentinelValue'

export async function GET(request: NextRequest, { params }: { params: {} }) {
const fetcheda = await fetchRandomCached('a')
const fetchedb = await fetchRandomCached('b')
return new Response(
JSON.stringify({
value: getSentinelValue(),
random1: fetcheda,
random2: fetchedb,
})
)
}

const fetchRandomCached = async (entropy: string) => {
const response = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random?b=' + entropy,
{ cache: 'force-cache' }
)
return response.text()
}
30 changes: 30 additions & 0 deletions test/e2e/app-dir/dynamic-io/app/routes/fetch-mixed/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { NextRequest } from 'next/server'

import { getSentinelValue } from '../../getSentinelValue'

export async function GET(request: NextRequest, { params }: { params: {} }) {
const fetcheda = await fetchRandomCached('a')
const fetchedb = await fetchRandomUncached('b')
return new Response(
JSON.stringify({
value: getSentinelValue(),
random1: fetcheda,
random2: fetchedb,
})
)
}

const fetchRandomCached = async (entropy: string) => {
const response = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random?b=' + entropy,
{ cache: 'force-cache' }
)
return response.text()
}

const fetchRandomUncached = async (entropy: string) => {
const response = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random?b=' + entropy
)
return response.text()
}
25 changes: 25 additions & 0 deletions test/e2e/app-dir/dynamic-io/app/routes/io-cached/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { NextRequest } from 'next/server'

import { unstable_cache as cache } from 'next/cache'

import { getSentinelValue } from '../../getSentinelValue'

export async function GET(request: NextRequest, { params }: { params: {} }) {
const messagea = await getCachedMessage('hello cached fast', 0)
const messageb = await getCachedMessage('hello cached slow', 20)
return new Response(
JSON.stringify({
value: getSentinelValue(),
message1: messagea,
message2: messageb,
})
)
}

async function getMessage(echo, delay) {
const tag = ((Math.random() * 10000) | 0).toString(16)
await new Promise((r) => setTimeout(r, delay))
return `${tag}:${echo}`
}

const getCachedMessage = cache(getMessage)
Loading

0 comments on commit b6ab2cd

Please sign in to comment.