diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts index c6b7035ad538b..15902682a716c 100644 --- a/packages/next/src/build/entries.ts +++ b/packages/next/src/build/entries.ts @@ -792,6 +792,12 @@ export async function createEntrypoints( await Promise.all(promises) + // Optimization: If there's only one instrumentation hook in edge compiler, which means there's no edge server entry. + // We remove the edge instrumentation entry from edge compiler as it can be pure server side. + if (edgeServer.instrumentation && Object.keys(edgeServer).length === 1) { + delete edgeServer.instrumentation + } + return { client, server, diff --git a/packages/next/src/build/templates/middleware.ts b/packages/next/src/build/templates/middleware.ts index 43eb1862916d9..7d1098fea4f51 100644 --- a/packages/next/src/build/templates/middleware.ts +++ b/packages/next/src/build/templates/middleware.ts @@ -40,6 +40,7 @@ function errorHandledHandler(fn: AdapterOptions['handler']) { routerKind: 'Pages Router', routePath: '/middleware', routeType: 'middleware', + revalidateReason: undefined, } ) diff --git a/packages/next/src/server/api-utils/node/api-resolver.ts b/packages/next/src/server/api-utils/node/api-resolver.ts index 8421af9eac356..ced64c1dd5edf 100644 --- a/packages/next/src/server/api-utils/node/api-resolver.ts +++ b/packages/next/src/server/api-utils/node/api-resolver.ts @@ -441,6 +441,7 @@ export async function apiResolver( routerKind: 'Pages Router', routePath: page || '', routeType: 'route', + revalidateReason: undefined, }) if (err instanceof ApiError) { diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index ca7170bd6d2f2..7d157646daa33 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -132,6 +132,7 @@ import { getServerActionRequestMetadata } from '../lib/server-action-request-met import { createInitialRouterState } from '../../client/components/router-reducer/create-initial-router-state' import { createMutableActionQueue } from '../../shared/lib/router/action-queue' import { prerenderAsyncStorage } from './prerender-async-storage.external' +import { getRevalidateReason } from '../instrumentation/utils' export type GetDynamicParamFromSegment = ( // [slug] / [[slug]] / [...slug] @@ -420,6 +421,7 @@ function createErrorContext( routePath: ctx.pagePath, routeType: ctx.isAction ? 'action' : 'render', renderSource, + revalidateReason: getRevalidateReason(ctx.staticGenerationStore), } } /** diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 4860d5037d322..fd078d9be91bb 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -161,6 +161,7 @@ import { } from './after/builtin-request-context' import { ENCODED_TAGS } from './stream-utils/encodedTags' import { NextRequestHint } from './web/adapter' +import { getRevalidateReason } from './instrumentation/utils' import { RouteKind } from './route-kind' import type { RouteModule } from './route-modules/route-module' @@ -2520,17 +2521,18 @@ export default abstract class Server< ) return null } catch (err) { - // If this is during static generation, throw the error again. - if (isSSG) throw err - - Log.error(err) - await this.instrumentationOnRequestError(err, req, { routerKind: 'App Router', routePath: pathname, routeType: 'route', + revalidateReason: getRevalidateReason(renderOpts), }) + // If this is during static generation, throw the error again. + if (isSSG) throw err + + Log.error(err) + // Otherwise, send a 500 response. await sendResponse(req, res, handleInternalServerErrorResponse()) @@ -2579,6 +2581,10 @@ export default abstract class Server< routerKind: 'Pages Router', routePath: pathname, routeType: 'render', + revalidateReason: getRevalidateReason({ + isRevalidate: isSSG, + isOnDemandRevalidate: renderOpts.isOnDemandRevalidate, + }), }) throw err } diff --git a/packages/next/src/server/instrumentation/types.ts b/packages/next/src/server/instrumentation/types.ts index 51b1917a94b30..0f8d81f756fca 100644 --- a/packages/next/src/server/instrumentation/types.ts +++ b/packages/next/src/server/instrumentation/types.ts @@ -6,6 +6,7 @@ export type RequestErrorContext = { | 'react-server-components' | 'react-server-components-payload' | 'server-rendering' + revalidateReason: 'on-demand' | 'stale' | undefined // TODO: other future instrumentation context } diff --git a/packages/next/src/server/instrumentation/utils.ts b/packages/next/src/server/instrumentation/utils.ts new file mode 100644 index 0000000000000..3c3c0c644751a --- /dev/null +++ b/packages/next/src/server/instrumentation/utils.ts @@ -0,0 +1,12 @@ +export function getRevalidateReason(params: { + isOnDemandRevalidate?: boolean + isRevalidate?: boolean +}): 'on-demand' | 'stale' | undefined { + if (params.isOnDemandRevalidate) { + return 'on-demand' + } + if (params.isRevalidate) { + return 'stale' + } + return undefined +} diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 63a8a06cf0504..ae7671ac4afc5 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -984,6 +984,8 @@ export default class NextNodeServer extends BaseServer< routePath: match.definition.page, routerKind: 'Pages Router', routeType: 'route', + // Edge runtime does not support ISR + revalidateReason: undefined, }) throw apiError } diff --git a/test/e2e/on-request-error/isr/app/app/on-demand/page.js b/test/e2e/on-request-error/isr/app/app/on-demand/page.js new file mode 100644 index 0000000000000..c2cfd2ee06c3e --- /dev/null +++ b/test/e2e/on-request-error/isr/app/app/on-demand/page.js @@ -0,0 +1,8 @@ +export default function Page() { + if (process.env.NEXT_PHASE !== 'phase-production-build') { + throw new Error('app:on-demand') + } + return

{Date.now()}

+} + +export const revalidate = 1000 diff --git a/test/e2e/on-request-error/isr/app/app/route/on-demand/route.js b/test/e2e/on-request-error/isr/app/app/route/on-demand/route.js new file mode 100644 index 0000000000000..368a11ce7c61f --- /dev/null +++ b/test/e2e/on-request-error/isr/app/app/route/on-demand/route.js @@ -0,0 +1,8 @@ +export function GET() { + if (process.env.NEXT_PHASE !== 'phase-production-build') { + throw new Error('app:route:on-demand') + } + return new Response('app:route') +} + +export const revalidate = 1000 diff --git a/test/e2e/on-request-error/isr/app/app/route/stale/route.js b/test/e2e/on-request-error/isr/app/app/route/stale/route.js new file mode 100644 index 0000000000000..70431034886a9 --- /dev/null +++ b/test/e2e/on-request-error/isr/app/app/route/stale/route.js @@ -0,0 +1,8 @@ +export function GET() { + if (process.env.NEXT_PHASE !== 'phase-production-build') { + throw new Error('app:route:stale') + } + return new Response('app:route') +} + +export const revalidate = 2 diff --git a/test/e2e/on-request-error/isr/app/app/self-revalidate/action.js b/test/e2e/on-request-error/isr/app/app/self-revalidate/action.js new file mode 100644 index 0000000000000..815a97b132a45 --- /dev/null +++ b/test/e2e/on-request-error/isr/app/app/self-revalidate/action.js @@ -0,0 +1,7 @@ +'use server' + +import { revalidatePath } from 'next/cache' + +export async function revalidateSelf() { + revalidatePath('/app/self-revalidate') +} diff --git a/test/e2e/on-request-error/isr/app/app/self-revalidate/page.js b/test/e2e/on-request-error/isr/app/app/self-revalidate/page.js new file mode 100644 index 0000000000000..42330d94f5fc6 --- /dev/null +++ b/test/e2e/on-request-error/isr/app/app/self-revalidate/page.js @@ -0,0 +1,21 @@ +'use client' + +import { revalidateSelf } from './action' + +export default function Page() { + if (typeof window === 'undefined') { + if (process.env.NEXT_PHASE !== 'phase-production-build') { + throw new Error('app:self-revalidate') + } + } + return ( + + ) +} diff --git a/test/e2e/on-request-error/isr/app/app/stale/page.js b/test/e2e/on-request-error/isr/app/app/stale/page.js new file mode 100644 index 0000000000000..68e00ffdfe226 --- /dev/null +++ b/test/e2e/on-request-error/isr/app/app/stale/page.js @@ -0,0 +1,7 @@ +export default function Page() { + if (process.env.NEXT_PHASE !== 'phase-production-build') + throw new Error('app:stale') + return

{Date.now()}

+} + +export const revalidate = 2 diff --git a/test/e2e/on-request-error/isr/app/layout.js b/test/e2e/on-request-error/isr/app/layout.js new file mode 100644 index 0000000000000..8525f5f8c0b2a --- /dev/null +++ b/test/e2e/on-request-error/isr/app/layout.js @@ -0,0 +1,12 @@ +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/on-request-error/isr/instrumentation.js b/test/e2e/on-request-error/isr/instrumentation.js new file mode 100644 index 0000000000000..bceaf1dda7512 --- /dev/null +++ b/test/e2e/on-request-error/isr/instrumentation.js @@ -0,0 +1,31 @@ +import fs from 'fs' +import fsp from 'fs/promises' +import path from 'path' + +const dir = path.dirname(new URL(import.meta.url).pathname) +const logPath = path.join(dir, 'output-log.json') + +export async function register() { + await fsp.writeFile(logPath, '{}', 'utf8') +} + +// Since only Node.js runtime support ISR, we can just write the error state to a file here. +// `onRequestError` will only be bundled within the Node.js runtime. +export async function onRequestError(err, request, context) { + const payload = { + message: err.message, + request, + context, + } + + const json = fs.existsSync(logPath) + ? JSON.parse(await fsp.readFile(logPath, 'utf8')) + : {} + + json[payload.message] = payload + + console.log( + `[instrumentation] write-log:${payload.message} ${payload.context.revalidateReason}` + ) + await fsp.writeFile(logPath, JSON.stringify(json, null, 2), 'utf8') +} diff --git a/test/e2e/on-request-error/isr/isr.test.ts b/test/e2e/on-request-error/isr/isr.test.ts new file mode 100644 index 0000000000000..3bb73ced425a0 --- /dev/null +++ b/test/e2e/on-request-error/isr/isr.test.ts @@ -0,0 +1,93 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry, waitFor } from 'next-test-utils' +import { getOutputLogJson } from '../_testing/utils' + +const outputLogPath = 'output-log.json' + +describe('on-request-error - isr', () => { + const { next, skipped, isNextDev } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + + if (skipped) { + return + } + + if (isNextDev) { + it('should skip in development mode', () => { + // This ISR test is only applicable for production mode + }) + return + } + + async function matchRevalidateReason( + errorMessage: string, + revalidateReason: string + ) { + await retry(async () => { + const json = await getOutputLogJson(next, outputLogPath) + expect(json[errorMessage]).toMatchObject({ + context: { + revalidateReason, + }, + }) + }) + } + + describe('app router ISR', () => { + it('should capture correct reason for stale errored page', async () => { + await next.fetch('/app/stale') + await waitFor(2 * 1000) // wait for revalidation + await next.fetch('/app/stale') + + await matchRevalidateReason('app:stale', 'stale') + }) + + it('should capture correct reason for on-demand revalidated page', async () => { + await next.fetch('/api/revalidate-path?path=/app/on-demand') + + await matchRevalidateReason('app:on-demand', 'on-demand') + }) + + it('should capture correct reason for build errored route', async () => { + await next.fetch('/app/route/stale') + await waitFor(2 * 1000) // wait for revalidation + await next.fetch('/app/route/stale') + + await matchRevalidateReason('app:route:stale', 'stale') + }) + + it('should capture correct reason for on-demand revalidated route', async () => { + await next.fetch('/api/revalidate-path?path=/app/route/on-demand') + + await matchRevalidateReason('app:route:on-demand', 'on-demand') + }) + + it('should capture revalidate from server action', async () => { + const browser = await next.browser('/app/self-revalidate') + const button = await browser.elementByCss('button') + await button.click() + + await retry(async () => { + await next.fetch('/app/self-revalidate') + await matchRevalidateReason('app:self-revalidate', 'stale') + }) + }) + }) + + describe('pages router ISR', () => { + it('should capture correct reason for stale errored page', async () => { + await next.fetch('/pages/stale') + await waitFor(2 * 1000) // wait for revalidation + await next.fetch('/pages/stale') + + await matchRevalidateReason('pages:stale', 'stale') + }) + + it('should capture correct reason for on-demand revalidated page', async () => { + await next.fetch('/api/revalidate-path?path=/pages/on-demand') + await matchRevalidateReason('pages:on-demand', 'on-demand') + }) + }) +}) diff --git a/test/e2e/on-request-error/isr/next.config.js b/test/e2e/on-request-error/isr/next.config.js new file mode 100644 index 0000000000000..c4cf84a76553b --- /dev/null +++ b/test/e2e/on-request-error/isr/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + instrumentationHook: true, + }, +} diff --git a/test/e2e/on-request-error/isr/pages/api/revalidate-path.js b/test/e2e/on-request-error/isr/pages/api/revalidate-path.js new file mode 100644 index 0000000000000..2183f8cfd0f70 --- /dev/null +++ b/test/e2e/on-request-error/isr/pages/api/revalidate-path.js @@ -0,0 +1,11 @@ +export default async function handler(req, res) { + const { path } = req.query + try { + await res.revalidate(path) + return res.json({ revalidated: true }) + } catch (err) { + console.error('Failed to revalidate:', err) + } + + res.json({ revalidated: false }) +} diff --git a/test/e2e/on-request-error/isr/pages/pages/on-demand.js b/test/e2e/on-request-error/isr/pages/pages/on-demand.js new file mode 100644 index 0000000000000..4b6d507ec8223 --- /dev/null +++ b/test/e2e/on-request-error/isr/pages/pages/on-demand.js @@ -0,0 +1,16 @@ +export default function Page() { + if (typeof window === 'undefined') { + if (process.env.NEXT_PHASE !== 'phase-production-build') + throw new Error('pages:on-demand') + } + return

{Date.now()}

+} + +export async function getStaticProps() { + return { + props: { + key: 'value', + }, + revalidate: 1000, + } +} diff --git a/test/e2e/on-request-error/isr/pages/pages/stale.js b/test/e2e/on-request-error/isr/pages/pages/stale.js new file mode 100644 index 0000000000000..65244cc825153 --- /dev/null +++ b/test/e2e/on-request-error/isr/pages/pages/stale.js @@ -0,0 +1,17 @@ +export default function Page() { + if (typeof window === 'undefined') { + if (process.env.NEXT_PHASE !== 'phase-production-build') + throw new Error('pages:stale') + } + + return

{Date.now()}

+} + +export async function getStaticProps() { + return { + props: { + key: 'value', + }, + revalidate: 2, + } +} diff --git a/test/ppr-tests-manifest.json b/test/ppr-tests-manifest.json index 8bf04079f667b..b8fab0c850b44 100644 --- a/test/ppr-tests-manifest.json +++ b/test/ppr-tests-manifest.json @@ -30,6 +30,13 @@ "app dir - not found navigation - with overridden node env should be able to navigate to other page from root not-found page" ] }, + "test/e2e/on-request-error/isr/isr.test.ts": { + "failed": [ + "on-request-error - isr app router ISR should capture correct reason for stale errored page", + "on-request-error - isr app router ISR should capture correct reason for on-demand revalidated page", + "on-request-error - isr app router ISR should capture revalidate from server action" + ] + }, "test/e2e/opentelemetry/opentelemetry.test.ts": { "failed": [ "opentelemetry root context app router should handle RSC with fetch",