diff --git a/test/e2e/app-dir/next-after-app-deploy/app/edge/dynamic-page/page.js b/test/e2e/app-dir/next-after-app-deploy/app/edge/dynamic-page/page.js new file mode 100644 index 00000000000000..9f7255965b751b --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/edge/dynamic-page/page.js @@ -0,0 +1 @@ +export { default } from '../../nodejs/dynamic-page/page' diff --git a/test/e2e/app-dir/next-after-app-deploy/app/edge/layout.js b/test/e2e/app-dir/next-after-app-deploy/app/edge/layout.js new file mode 100644 index 00000000000000..b19ff8efbdce8e --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/edge/layout.js @@ -0,0 +1,3 @@ +export { default } from '../nodejs/layout' + +export const runtime = 'edge' diff --git a/test/e2e/app-dir/next-after-app-deploy/app/edge/middleware/page.js b/test/e2e/app-dir/next-after-app-deploy/app/edge/middleware/page.js new file mode 100644 index 00000000000000..6a1b84751684f2 --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/edge/middleware/page.js @@ -0,0 +1 @@ +export { default } from '../../nodejs/middleware/page' diff --git a/test/e2e/app-dir/next-after-app-deploy/app/edge/route/route.js b/test/e2e/app-dir/next-after-app-deploy/app/edge/route/route.js new file mode 100644 index 00000000000000..8426f52cb33cd3 --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/edge/route/route.js @@ -0,0 +1,4 @@ +export { GET } from '../../nodejs/route/route' + +export const runtime = 'edge' +export const dynamic = 'force-dynamic' diff --git a/test/e2e/app-dir/next-after-app-deploy/app/edge/server-action/page.js b/test/e2e/app-dir/next-after-app-deploy/app/edge/server-action/page.js new file mode 100644 index 00000000000000..2f493b57190a5a --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/edge/server-action/page.js @@ -0,0 +1 @@ +export { default } from '../../nodejs/server-action/page' diff --git a/test/e2e/app-dir/next-after-app-deploy/app/layout.js b/test/e2e/app-dir/next-after-app-deploy/app/layout.js new file mode 100644 index 00000000000000..a55016aeb623b0 --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/layout.js @@ -0,0 +1,10 @@ +export default function AppLayout({ children }) { + return ( + + + after + + {children} + + ) +} diff --git a/test/e2e/app-dir/next-after-app-deploy/app/nodejs/dynamic-page/page.js b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/dynamic-page/page.js new file mode 100644 index 00000000000000..7e230bc7697edb --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/dynamic-page/page.js @@ -0,0 +1,11 @@ +import { unstable_after as after } from 'next/server' +import { revalidateTimestampPage } from '../../timestamp/revalidate' +import { pathPrefix } from '../../path-prefix' + +export default function Page() { + after(async () => { + await revalidateTimestampPage(pathPrefix + `/dynamic-page`) + }) + + return
Page with after()
+} diff --git a/test/e2e/app-dir/next-after-app-deploy/app/nodejs/layout.js b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/layout.js new file mode 100644 index 00000000000000..44f23cfc048f60 --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/layout.js @@ -0,0 +1,5 @@ +export const runtime = 'nodejs' + +export default function Layout({ children }) { + return <>{children} +} diff --git a/test/e2e/app-dir/next-after-app-deploy/app/nodejs/middleware/page.js b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/middleware/page.js new file mode 100644 index 00000000000000..6321fa7d959879 --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/middleware/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return
Redirect
+} diff --git a/test/e2e/app-dir/next-after-app-deploy/app/nodejs/route/route.js b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/route/route.js new file mode 100644 index 00000000000000..439bb58e455f35 --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/route/route.js @@ -0,0 +1,15 @@ +import { unstable_after as after } from 'next/server' +import { revalidateTimestampPage } from '../../timestamp/revalidate' +import { pathPrefix } from '../../path-prefix' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + const data = { message: 'Hello, world!' } + after(async () => { + await revalidateTimestampPage(pathPrefix + `/route`) + }) + + return Response.json({ data }) +} diff --git a/test/e2e/app-dir/next-after-app-deploy/app/nodejs/server-action/page.js b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/server-action/page.js new file mode 100644 index 00000000000000..d46488e9292d91 --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/server-action/page.js @@ -0,0 +1,20 @@ +import { unstable_after as after } from 'next/server' +import { revalidateTimestampPage } from '../../timestamp/revalidate' +import { pathPrefix } from '../../path-prefix' + +export default function Page() { + return ( +
+
{ + 'use server' + after(async () => { + await revalidateTimestampPage(pathPrefix + `/server-action`) + }) + }} + > + +
+
+ ) +} diff --git a/test/e2e/app-dir/next-after-app-deploy/app/path-prefix.js b/test/e2e/app-dir/next-after-app-deploy/app/path-prefix.js new file mode 100644 index 00000000000000..dfb4e0c7f8ff12 --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/path-prefix.js @@ -0,0 +1 @@ +export const pathPrefix = '/' + process.env.NEXT_RUNTIME diff --git a/test/e2e/app-dir/next-after-app-deploy/app/timestamp/key/[key]/page.js b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/key/[key]/page.js new file mode 100644 index 00000000000000..c15fc419f15aa4 --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/key/[key]/page.js @@ -0,0 +1,16 @@ +export const dynamic = 'error' +export const revalidate = 3600 // arbitrarily long, just so that it doesn't happen during a test run +export const dynamicParams = true + +export async function generateStaticParams() { + return [] +} + +export default function Page({ params }) { + const data = { + key: params.key, + timestamp: Date.now(), + } + console.log('/timestamp/key/[key] rendered', data) + return
{JSON.stringify(data)}
+} diff --git a/test/e2e/app-dir/next-after-app-deploy/app/timestamp/revalidate.js b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/revalidate.js new file mode 100644 index 00000000000000..b7142562cf4d5e --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/revalidate.js @@ -0,0 +1,33 @@ +import { revalidatePath } from 'next/cache' + +export async function revalidateTimestampPage(/** @type {string} */ key) { + const path = `/timestamp/key/${encodeURIComponent(key)}` + + const sleepDuration = getSleepDuration() + if (sleepDuration > 0) { + console.log(`revalidateTimestampPage :: sleeping for ${sleepDuration} ms`) + await sleep(sleepDuration) + } + + console.log('revalidateTimestampPage :: revalidating', path) + revalidatePath(path) +} + +const WAIT_BEFORE_REVALIDATING_DEFAULT = 5000 + +function getSleepDuration() { + const raw = process.env.WAIT_BEFORE_REVALIDATING + if (!raw) return WAIT_BEFORE_REVALIDATING_DEFAULT + + const parsed = Number.parseInt(raw) + if (Number.isNaN(parsed)) { + throw new Error( + `WAIT_BEFORE_REVALIDATING must be a valid number, got: ${JSON.stringify(raw)}` + ) + } + return parsed +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/test/e2e/app-dir/next-after-app-deploy/app/timestamp/trigger-revalidate/route.js b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/trigger-revalidate/route.js new file mode 100644 index 00000000000000..18eb6a554db42a --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/trigger-revalidate/route.js @@ -0,0 +1,14 @@ +import { revalidateTimestampPage } from '../revalidate' + +export async function POST(/** @type {Request} */ request) { + // we can't call revalidatePath from middleware, so we need to do it from here instead + const path = new URL(request.url).searchParams.get('path') + if (!path) { + return Response.json( + { message: 'Missing "path" search param' }, + { status: 400 } + ) + } + await revalidateTimestampPage(path) + return Response.json({}) +} diff --git a/test/e2e/app-dir/next-after-app-deploy/index.test.ts b/test/e2e/app-dir/next-after-app-deploy/index.test.ts new file mode 100644 index 00000000000000..445ce4cf874b19 --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/index.test.ts @@ -0,0 +1,116 @@ +/* eslint-env jest */ +import { nextTestSetup, isNextDev, isNextDeploy } from 'e2e-utils' +import { retry } from 'next-test-utils' + +const runtimes = ['nodejs', 'edge'] + +const WAIT_BEFORE_REVALIDATING = isNextDeploy ? 10_000 : 5_000 + +// If we want to verify that `unstable_after()` ran its callback, +// we need it to perform some kind of side effect (because it can't affect the response). +// In other tests, we often use logs for this, but we don't have access to those in deploy tests. +// So instead this test relies on calling `revalidatePath` inside `unstable_after` +// to revalidate an ISR page '/timestamp/key/[key]', and then checking if the timestamp changed -- +// if it did, we successfully ran the callback (and performed a side effect). + +// This test relies on revalidating a static page, so it can't work in dev mode. +const _describe = isNextDev ? describe : describe.skip + +_describe.each(runtimes)('unstable_after() in %s runtime', (runtimeValue) => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + env: { WAIT_BEFORE_REVALIDATING: WAIT_BEFORE_REVALIDATING + '' }, + }) + const retryDuration = WAIT_BEFORE_REVALIDATING * 2 + + if (skipped) return + const pathPrefix = '/' + runtimeValue + + type PageInfo = { + key: string + timestamp: number + } + + const getTimestampPageData = async (path: string): Promise => { + const fullPath = `/timestamp/key/${encodeURIComponent(path)}` + const response = await next.render$(fullPath) + const dataStr = response('#page-info').text() + if (!dataStr) { + throw new Error(`No page data found for '${fullPath}'`) + } + return JSON.parse(dataStr) as PageInfo + } + + it('triggers revalidate from a page', async () => { + const path = pathPrefix + '/dynamic-page' + const dataBefore = await getTimestampPageData(path) + expect(dataBefore).toEqual(await getTimestampPageData(path)) // sanity check that it's static + + await next.render(path) // trigger revalidate + + await retry( + async () => { + const dataAfter = await getTimestampPageData(path) + expect(dataAfter.timestamp).toBeGreaterThan(dataBefore.timestamp) + }, + retryDuration, + 1000, + 'check if timestamp page updated' + ) + }) + + it('triggers revalidate from a server action', async () => { + const path = pathPrefix + '/server-action' + const dataBefore = await getTimestampPageData(path) + expect(dataBefore).toEqual(await getTimestampPageData(path)) // sanity check that it's static + + const session = await next.browser(path) + await session.elementByCss('button[type="submit"]').click() // trigger revalidate + + await retry( + async () => { + const dataAfter = await getTimestampPageData(path) + expect(dataAfter.timestamp).toBeGreaterThan(dataBefore.timestamp) + }, + retryDuration, + 1000, + 'check if timestamp page updated' + ) + }) + + it('triggers revalidate from a route handler', async () => { + const path = pathPrefix + '/route' + const dataBefore = await getTimestampPageData(path) + expect(dataBefore).toEqual(await getTimestampPageData(path)) // sanity check that it's static + + await next.fetch(path).then((res) => res.text()) // trigger revalidate + + await retry( + async () => { + const dataAfter = await getTimestampPageData(path) + expect(dataAfter.timestamp).toBeGreaterThan(dataBefore.timestamp) + }, + retryDuration, + 1000, + 'check if timestamp page updated' + ) + }) + + it('triggers revalidate from middleware', async () => { + const path = pathPrefix + '/middleware' + const dataBefore = await getTimestampPageData(path) + expect(dataBefore).toEqual(await getTimestampPageData(path)) // sanity check that it's static + + await next.render(path) // trigger revalidate + + await retry( + async () => { + const dataAfter = await getTimestampPageData(path) + expect(dataAfter.timestamp).toBeGreaterThan(dataBefore.timestamp) + }, + retryDuration, + 1000, + 'check if timestamp page updated' + ) + }) +}) diff --git a/test/e2e/app-dir/next-after-app-deploy/middleware.js b/test/e2e/app-dir/next-after-app-deploy/middleware.js new file mode 100644 index 00000000000000..b3ca2ecbd3def0 --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/middleware.js @@ -0,0 +1,30 @@ +import { unstable_after as after } from 'next/server' + +export function middleware( + /** @type {import ('next/server').NextRequest} */ request +) { + const url = new URL(request.url) + + const match = url.pathname.match(/^(?\/[^/]+?)\/middleware/) + if (match) { + const pathPrefix = match.groups.prefix + after(async () => { + // we can't call revalidatePath from middleware, so we need to do it via an endpoint instead + const pathToRevalidate = pathPrefix + `/middleware` + + const postUrl = new URL('/timestamp/trigger-revalidate', url.href) + postUrl.searchParams.append('path', pathToRevalidate) + + const response = await fetch(postUrl, { method: 'POST' }) + if (!response.ok) { + throw new Error( + `Failed to revalidate path '${pathToRevalidate}' (status: ${response.status})` + ) + } + }) + } +} + +export const config = { + matcher: ['/:prefix/middleware'], +} diff --git a/test/e2e/app-dir/next-after-app-deploy/next.config.js b/test/e2e/app-dir/next-after-app-deploy/next.config.js new file mode 100644 index 00000000000000..992cdf8821d4e2 --- /dev/null +++ b/test/e2e/app-dir/next-after-app-deploy/next.config.js @@ -0,0 +1,8 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + experimental: { + after: true, + // DO NOT turn this on, it disables the incremental cache! (see `disableForTestmode`) + // testProxy: true, + }, +}