diff --git a/packages/next/src/export/routes/app-route.ts b/packages/next/src/export/routes/app-route.ts index 4f7721521319a..c72d1004e9cb6 100644 --- a/packages/next/src/export/routes/app-route.ts +++ b/packages/next/src/export/routes/app-route.ts @@ -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 } } diff --git a/packages/next/src/server/route-modules/app-route/module.ts b/packages/next/src/server/route-modules/app-route/module.ts index 1ee79aa826ce6..1a639ef7e2e5b 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -47,7 +47,10 @@ 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' @@ -55,9 +58,16 @@ 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 @@ -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. @@ -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).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) + 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.` @@ -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` + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-cookies/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-cookies/route.ts new file mode 100644 index 0000000000000..584fb3ac47cbb --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-cookies/route.ts @@ -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, + }) + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-headers/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-headers/route.ts new file mode 100644 index 0000000000000..c64224be376f6 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-headers/route.ts @@ -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, + }) + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-stream/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-stream/route.ts new file mode 100644 index 0000000000000..31f8a49679d61 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-stream/route.ts @@ -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) +} diff --git a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-url/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-url/route.ts new file mode 100644 index 0000000000000..a541adcef1ed4 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-url/route.ts @@ -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, + }) + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/routes/fetch-cached/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/fetch-cached/route.ts new file mode 100644 index 0000000000000..056897cc8fe9b --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/fetch-cached/route.ts @@ -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() +} diff --git a/test/e2e/app-dir/dynamic-io/app/routes/fetch-mixed/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/fetch-mixed/route.ts new file mode 100644 index 0000000000000..43199c37abbeb --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/fetch-mixed/route.ts @@ -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() +} diff --git a/test/e2e/app-dir/dynamic-io/app/routes/io-cached/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/io-cached/route.ts new file mode 100644 index 0000000000000..e464d99b9c66c --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/io-cached/route.ts @@ -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) diff --git a/test/e2e/app-dir/dynamic-io/app/routes/io-mixed/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/io-mixed/route.ts new file mode 100644 index 0000000000000..0ac5ab4cdbbf0 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/io-mixed/route.ts @@ -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 getMessage('hello uncached 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) diff --git a/test/e2e/app-dir/dynamic-io/app/routes/microtask/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/microtask/route.ts new file mode 100644 index 0000000000000..b198063d7a42c --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/microtask/route.ts @@ -0,0 +1,12 @@ +import type { NextRequest } from 'next/server' + +import { getSentinelValue } from '../../getSentinelValue' + +export async function GET(request: NextRequest, { params }: { params: {} }) { + await Promise.resolve() + const response = JSON.stringify({ + value: getSentinelValue(), + message: 'microtask', + }) + return new Response(response) +} diff --git a/test/e2e/app-dir/dynamic-io/app/routes/static-stream-async/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/static-stream-async/route.ts new file mode 100644 index 0000000000000..bea5713974cac --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/static-stream-async/route.ts @@ -0,0 +1,26 @@ +import type { NextRequest } from 'next/server' + +import { getSentinelValue } from '../../getSentinelValue' + +export async function GET(request: NextRequest, { params }: { params: {} }) { + const response = JSON.stringify({ + value: getSentinelValue(), + message: 'stream response', + }) + const part1 = response.slice(0, Math.floor(response.length / 2)) + const part2 = response.slice(Math.floor(response.length / 2)) + + const encoder = new TextEncoder() + const chunks = [encoder.encode(part1), encoder.encode(part2)] + + let sent = 0 + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(chunks[sent++]) + if (sent === chunks.length) { + controller.close() + } + }, + }) + return new Response(stream) +} diff --git a/test/e2e/app-dir/dynamic-io/app/routes/static-stream-sync/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/static-stream-sync/route.ts new file mode 100644 index 0000000000000..2c9347f48a764 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/static-stream-sync/route.ts @@ -0,0 +1,26 @@ +import type { NextRequest } from 'next/server' + +import { getSentinelValue } from '../../getSentinelValue' + +export function GET(request: NextRequest, { params }: { params: {} }) { + const response = JSON.stringify({ + value: getSentinelValue(), + message: 'stream response', + }) + const part1 = response.slice(0, Math.floor(response.length / 2)) + const part2 = response.slice(Math.floor(response.length / 2)) + + const encoder = new TextEncoder() + const chunks = [encoder.encode(part1), encoder.encode(part2)] + + let sent = 0 + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(chunks[sent++]) + if (sent === chunks.length) { + controller.close() + } + }, + }) + return new Response(stream) +} diff --git a/test/e2e/app-dir/dynamic-io/app/routes/static-string-async/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/static-string-async/route.ts new file mode 100644 index 0000000000000..b4019a7e588da --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/static-string-async/route.ts @@ -0,0 +1,11 @@ +import type { NextRequest } from 'next/server' + +import { getSentinelValue } from '../../getSentinelValue' + +export async function GET(request: NextRequest, { params }: { params: {} }) { + const response = JSON.stringify({ + value: getSentinelValue(), + message: 'string response', + }) + return new Response(response) +} diff --git a/test/e2e/app-dir/dynamic-io/app/routes/static-string-sync/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/static-string-sync/route.ts new file mode 100644 index 0000000000000..1e986b9db467c --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/static-string-sync/route.ts @@ -0,0 +1,11 @@ +import type { NextRequest } from 'next/server' + +import { getSentinelValue } from '../../getSentinelValue' + +export function GET(request: NextRequest, { params }: { params: {} }) { + const response = JSON.stringify({ + value: getSentinelValue(), + message: 'string response', + }) + return new Response(response) +} diff --git a/test/e2e/app-dir/dynamic-io/app/routes/task/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/task/route.ts new file mode 100644 index 0000000000000..34e00162b49b0 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/task/route.ts @@ -0,0 +1,12 @@ +import type { NextRequest } from 'next/server' + +import { getSentinelValue } from '../../getSentinelValue' + +export async function GET(request: NextRequest, { params }: { params: {} }) { + await new Promise((r) => setTimeout(r, 10)) + const response = JSON.stringify({ + value: getSentinelValue(), + message: 'task', + }) + return new Response(response) +} diff --git a/test/e2e/app-dir/dynamic-io/dynamic-io.routes.test.ts b/test/e2e/app-dir/dynamic-io/dynamic-io.routes.test.ts new file mode 100644 index 0000000000000..585d6b96671bb --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/dynamic-io.routes.test.ts @@ -0,0 +1,227 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('dynamic-io', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + + if (skipped) { + return + } + + it('should not prerender GET route handlers that use dynamic APIs', async () => { + let str = await next.render('/routes/dynamic-cookies', {}) + let json = JSON.parse(str) + + expect(json.value).toEqual('at runtime') + expect(json.type).toEqual('cookies') + + str = await next.render('/routes/dynamic-headers', {}) + json = JSON.parse(str) + + expect(json.value).toEqual('at runtime') + expect(json.type).toEqual('headers') + + str = await next.render('/routes/dynamic-stream', {}) + json = JSON.parse(str) + + expect(json.value).toEqual('at runtime') + expect(json.message).toEqual('dynamic stream') + + str = await next.render('/routes/dynamic-url?foo=bar', {}) + json = JSON.parse(str) + + expect(json.value).toEqual('at runtime') + expect(json.search).toEqual('?foo=bar') + }) + + it('should prerender GET route handlers that have entirely cached io (fetches)', async () => { + let str = await next.render('/routes/fetch-cached', {}) + let json = JSON.parse(str) + + const random1 = json.random1 + const random2 = json.random2 + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(typeof random1).toBe('string') + expect(typeof random2).toBe('string') + } else { + expect(json.value).toEqual('at buildtime') + expect(typeof random1).toBe('string') + expect(typeof random2).toBe('string') + } + + str = await next.render('/routes/fetch-cached', {}) + json = JSON.parse(str) + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(random1).toEqual(json.random1) + expect(random2).toEqual(json.random2) + } else { + expect(json.value).toEqual('at buildtime') + expect(random1).toEqual(json.random1) + expect(random2).toEqual(json.random2) + } + }) + + it('should not prerender GET route handlers that have some uncached io (fetches)', async () => { + let str = await next.render('/routes/fetch-mixed', {}) + let json = JSON.parse(str) + + const random1 = json.random1 + const random2 = json.random2 + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(typeof random1).toBe('string') + expect(typeof random2).toBe('string') + } else { + expect(json.value).toEqual('at runtime') + expect(typeof random1).toBe('string') + expect(typeof random2).toBe('string') + } + + str = await next.render('/routes/fetch-mixed', {}) + json = JSON.parse(str) + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(random1).toEqual(json.random1) + expect(random2).not.toEqual(json.random2) + } else { + expect(json.value).toEqual('at runtime') + expect(random1).toEqual(json.random1) + expect(random2).not.toEqual(json.random2) + } + }) + + it('should prerender GET route handlers that have entirely cached io (unstable_cache)', async () => { + let str = await next.render('/routes/io-cached', {}) + let json = JSON.parse(str) + + const message1 = json.message1 + const message2 = json.message2 + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(typeof message1).toBe('string') + expect(typeof message2).toBe('string') + } else { + expect(json.value).toEqual('at buildtime') + expect(typeof message1).toBe('string') + expect(typeof message2).toBe('string') + } + + str = await next.render('/routes/io-cached', {}) + json = JSON.parse(str) + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(message1).toEqual(json.message1) + expect(message2).toEqual(json.message2) + } else { + expect(json.value).toEqual('at buildtime') + expect(message1).toEqual(json.message1) + expect(message2).toEqual(json.message2) + } + }) + + it('should not prerender GET route handlers that have some uncached io (unstable_cache)', async () => { + let str = await next.render('/routes/io-mixed', {}) + let json = JSON.parse(str) + + const message1 = json.message1 + const message2 = json.message2 + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(typeof message1).toBe('string') + expect(typeof message2).toBe('string') + } else { + expect(json.value).toEqual('at runtime') + expect(typeof message1).toBe('string') + expect(typeof message2).toBe('string') + } + + str = await next.render('/routes/io-mixed', {}) + json = JSON.parse(str) + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(message1).toEqual(json.message1) + expect(message2).not.toEqual(json.message2) + } else { + expect(json.value).toEqual('at runtime') + expect(message1).toEqual(json.message1) + expect(message2).not.toEqual(json.message2) + } + }) + + it('should prerender GET route handlers that complete synchronously or in a microtask', async () => { + let str = await next.render('/routes/microtask', {}) + let json = JSON.parse(str) + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(json.message).toBe('microtask') + } else { + expect(json.value).toEqual('at buildtime') + expect(json.message).toBe('microtask') + } + + str = await next.render('/routes/static-stream-sync', {}) + json = JSON.parse(str) + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(json.message).toBe('stream response') + } else { + expect(json.value).toEqual('at buildtime') + expect(json.message).toBe('stream response') + } + + str = await next.render('/routes/static-stream-async', {}) + json = JSON.parse(str) + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(json.message).toBe('stream response') + } else { + expect(json.value).toEqual('at buildtime') + expect(json.message).toBe('stream response') + } + + str = await next.render('/routes/static-string-sync', {}) + json = JSON.parse(str) + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(json.message).toBe('string response') + } else { + expect(json.value).toEqual('at buildtime') + expect(json.message).toBe('string response') + } + + str = await next.render('/routes/static-string-async', {}) + json = JSON.parse(str) + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(json.message).toBe('string response') + } else { + expect(json.value).toEqual('at buildtime') + expect(json.message).toBe('string response') + } + }) + + 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) + + expect(json.value).toEqual('at runtime') + expect(json.message).toBe('task') + }) +})