From e0a4e4887edfd8b4c0a3c09f13c27511db8b0a31 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Mon, 13 Oct 2025 16:29:43 +0200 Subject: [PATCH 1/2] test: add e2e tests for next@16 updateTag and revalidateTag with expiration profiles --- tests/e2e/on-demand-app.test.ts | 296 +++++++++++++++++- .../app/api/purge-cdn/route.ts | 40 +++ .../app/api/revalidate-tag/route.ts | 27 ++ .../next-16-tag-revalidation/app/layout.js | 12 + .../product-static/page.js | 35 +++ .../product/[slug]/page.js | 44 +++ .../product-static/page.js | 35 +++ .../product/[slug]/page.js | 44 +++ .../app/update-tag/product-static/page.js | 46 +++ .../app/update-tag/product/[slug]/page.js | 55 ++++ .../next-16-tag-revalidation/next-env.d.ts | 6 + .../next-16-tag-revalidation/next.config.js | 16 + .../next-16-tag-revalidation/package.json | 26 ++ .../next-16-tag-revalidation/tsconfig.json | 24 ++ tests/prepare.mjs | 1 + tests/utils/create-e2e-fixture.ts | 1 + 16 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/next-16-tag-revalidation/app/api/purge-cdn/route.ts create mode 100644 tests/fixtures/next-16-tag-revalidation/app/api/revalidate-tag/route.ts create mode 100644 tests/fixtures/next-16-tag-revalidation/app/layout.js create mode 100644 tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-explicit-inline-expire/product-static/page.js create mode 100644 tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-explicit-inline-expire/product/[slug]/page.js create mode 100644 tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-string-profile/product-static/page.js create mode 100644 tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-string-profile/product/[slug]/page.js create mode 100644 tests/fixtures/next-16-tag-revalidation/app/update-tag/product-static/page.js create mode 100644 tests/fixtures/next-16-tag-revalidation/app/update-tag/product/[slug]/page.js create mode 100644 tests/fixtures/next-16-tag-revalidation/next-env.d.ts create mode 100644 tests/fixtures/next-16-tag-revalidation/next.config.js create mode 100644 tests/fixtures/next-16-tag-revalidation/package.json create mode 100644 tests/fixtures/next-16-tag-revalidation/tsconfig.json diff --git a/tests/e2e/on-demand-app.test.ts b/tests/e2e/on-demand-app.test.ts index b4e6ea63aa..4cc571bfc6 100644 --- a/tests/e2e/on-demand-app.test.ts +++ b/tests/e2e/on-demand-app.test.ts @@ -2,7 +2,7 @@ import { expect } from '@playwright/test' import { test } from '../utils/playwright-helpers.js' import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' -test.describe('app router on-demand revalidation', () => { +test.describe('app router on-demand revalidation (pre Next 16 APIs)', () => { for (const { label, prerendered, pagePath, revalidateApiPath, expectedH1Content } of [ { label: 'revalidatePath (prerendered page with static path)', @@ -193,3 +193,297 @@ test.describe('app router on-demand revalidation', () => { }) } }) + +if (nextVersionSatisfies('>=16.0.0-alpha.0')) { + test.describe('app router on-demand revalidation (Next 16 APIs)', () => { + for (const { label, prerendered, pagePathSuffix, tagSuffix, expectedH1Content } of [ + { + label: 'prerendered page with static path', + prerendered: true, + pagePathSuffix: '/product-static', + tagSuffix: 'product-static', + expectedH1Content: 'Product product-static', + }, + { + label: 'prerendered page with dynamic path', + prerendered: true, + pagePathSuffix: '/product/prerendered', + tagSuffix: 'prerendered', + expectedH1Content: 'Product prerendered', + }, + { + label: 'not prerendered page with dynamic path', + prerendered: false, + pagePathSuffix: '/product/not-prerendered', + tagSuffix: 'not-prerendered', + expectedH1Content: 'Product not-prerendered', + }, + ]) { + test.describe(label, () => { + for (const { label, revalidateApiProfileSuffix, tagPrefix } of [ + { + label: 'revalidateTag with string profile', + revalidateApiProfileSuffix: `profile=testCacheLife`, + tagPrefix: `revalidate-tag-string-profile`, + }, + { + label: 'revalidateTag with explicit inline expire', + revalidateApiProfileSuffix: `expire=5`, + tagPrefix: `revalidate-tag-explicit-inline-expire`, + }, + ]) { + test(label, async ({ page, pollUntilHeadersMatch, next16TagRevalidation }) => { + const pagePath = `/${tagPrefix}${pagePathSuffix}` + const revalidateApiPath = `/api/revalidate-tag?tag=${tagPrefix}-${tagSuffix}&${revalidateApiProfileSuffix}` + + // in case there is retry or some other test did hit that path before + // we want to make sure that cdn cache is not warmed up + const purgeCdnCache = await page.goto( + new URL(`/api/purge-cdn?path=${pagePath}`, next16TagRevalidation.url).href, + ) + expect(purgeCdnCache?.status()).toBe(200) + + // wait a bit until cdn cache purge propagates + await page.waitForTimeout(500) + + const response1 = await pollUntilHeadersMatch( + new URL(pagePath, next16TagRevalidation.url).href, + { + headersToMatch: { + // either first time hitting this route or we invalidated + // just CDN node in earlier step + // we will invoke function and see Next cache hit status + // in the response if it was prerendered at build time + // or regenerated in previous attempt to run this test + 'cache-status': [ + /"Netlify Edge"; fwd=(miss|stale)/m, + prerendered ? /"Next.js"; hit/m : /"Next.js"; (hit|fwd=miss)/m, + ], + }, + headersNotMatchedMessage: + 'First request to tested page should be a miss or stale on the Edge and hit in Next.js', + }, + ) + const headers1 = response1?.headers() || {} + expect(response1?.status()).toBe(200) + expect(headers1['x-nextjs-cache']).toBeUndefined() + expect(headers1['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable') + + const date1 = await page.getByTestId('date-now').textContent() + + const h1 = await page.locator('h1').textContent() + expect(h1).toBe(expectedH1Content) + + const response2 = await pollUntilHeadersMatch( + new URL(pagePath, next16TagRevalidation.url).href, + { + headersToMatch: { + // we are hitting the same page again and we most likely will see + // CDN hit (in this case Next reported cache status is omitted + // as it didn't actually take place in handling this request) + // or we will see CDN miss because different CDN node handled request + 'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m, + }, + headersNotMatchedMessage: + 'Second request to tested page should most likely be a hit on the Edge (optionally miss or stale if different CDN node)', + }, + ) + const headers2 = response2?.headers() || {} + expect(response2?.status()).toBe(200) + expect(headers2['x-nextjs-cache']).toBeUndefined() + if (!headers2['cache-status'].includes('"Netlify Edge"; hit')) { + // if we missed CDN cache, we will see Next cache hit status + // as we reuse cached response + expect(headers2['cache-status']).toMatch(/"Next.js"; hit/m) + } + expect(headers2['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable') + + // the page is cached + const date2 = await page.getByTestId('date-now').textContent() + expect(date2).toBe(date1) + + const revalidate = await page.goto( + new URL(revalidateApiPath, next16TagRevalidation.url).href, + ) + expect(revalidate?.status()).toBe(200) + + // wait a bit until cdn tags and invalidated and cdn is purged + await page.waitForTimeout(500) + + // now after the revalidation with delayed expiration, it should serve stale if we are still before expiration time was not reached + const response3 = await pollUntilHeadersMatch( + new URL(pagePath, next16TagRevalidation.url).href, + { + headersToMatch: { + // revalidatePath just marks the page(s) as stale and does NOT + // automatically refreshes the cache. This request should result + // in serving stale content and trigger background revalidation. + 'cache-status': [ + /"Next.js"; hit; fwd=stale/m, + /"Netlify Edge"; fwd=(miss|stale)/m, + ], + }, + headersNotMatchedMessage: + 'Third request to tested page should be a miss or stale on the Edge and stale in Next.js after on-demand revalidation with delayed expiration', + }, + ) + const headers3 = response3?.headers() || {} + expect(response3?.status()).toBe(200) + expect(headers3?.['x-nextjs-cache']).toBeUndefined() + expect(headers3['debug-netlify-cdn-cache-control'], 'Stale is not cacheable').toBe( + 'public, max-age=0, must-revalidate, durable', + ) + + // the page is stale but still served, because we hit it before expiration + const date3 = await page.getByTestId('date-now').textContent() + expect(date3).toBe(date2) + + // previous request should trigger background revalidation. There is 5s sleep in data fetching in tested page + // so let's wait for that + + await page.waitForTimeout(6000) + + const response4 = await pollUntilHeadersMatch( + new URL(pagePath, next16TagRevalidation.url).href, + { + headersToMatch: { + // we are hitting the same page again and we most likely will see + // CDN hit (in this case Next reported cache status is omitted + // as it didn't actually take place in handling this request) + // or we will see CDN miss because different CDN node handled request + 'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m, + }, + headersNotMatchedMessage: + 'Fourth request to tested page should most likely be a hit on the Edge (optionally miss or stale if different CDN node)', + }, + ) + const headers4 = response4?.headers() || {} + expect(response4?.status()).toBe(200) + expect(headers4?.['x-nextjs-cache']).toBeUndefined() + if (!headers4['cache-status'].includes('"Netlify Edge"; hit')) { + // if we missed CDN cache, we will see Next cache hit status + // as we reuse cached response + expect(headers4['cache-status']).toMatch(/"Next.js"; hit/m) + } + expect(headers4['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable') + + // the page is cached + const date4 = await page.getByTestId('date-now').textContent() + expect(date4).not.toBe(date3) + + // lets revalidate again, but now we will wait for expiration time to pass to test that we are not serving stale anymore + const revalidate2 = await page.goto( + new URL(revalidateApiPath, next16TagRevalidation.url).href, + ) + expect(revalidate2?.status()).toBe(200) + + // revalidation should allow stale to be served for 5 seconds, let's wait to test case after expiration + await page.waitForTimeout(6000) + + // now after the revalidation it should have a different date + const response5 = await pollUntilHeadersMatch( + new URL(pagePath, next16TagRevalidation.url).href, + { + headersToMatch: { + // revalidatePath just marks the page(s) as invalid and does NOT + // automatically refreshes the cache. This request will cause + // Next.js cache miss and new response will be generated and cached + // Depending if we hit same CDN node as previous request, we might + // get either fwd=miss or fwd=stale + 'cache-status': [/"Next.js"; fwd=miss/m, /"Netlify Edge"; fwd=(miss|stale)/m], + }, + headersNotMatchedMessage: + 'Third request to tested page should be a miss or stale on the Edge and miss in Next.js after on-demand revalidation', + }, + ) + const headers5 = response5?.headers() || {} + expect(response5?.status()).toBe(200) + expect(headers5?.['x-nextjs-cache']).toBeUndefined() + expect(headers5['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable') + + // the page has now an updated date + const date5 = await page.getByTestId('date-now').textContent() + expect(date5).not.toBe(date4) + }) + } + + test('updateTag in server action', async ({ + page, + pollUntilHeadersMatch, + next16TagRevalidation, + }) => { + const pagePath = `/update-tag/${pagePathSuffix}` + // in case there is retry or some other test did hit that path before + // we want to make sure that cdn cache is not warmed up + const purgeCdnCache = await page.goto( + new URL(`/api/purge-cdn?path=${pagePath}`, next16TagRevalidation.url).href, + ) + expect(purgeCdnCache?.status()).toBe(200) + + // wait a bit until cdn cache purge propagates + await page.waitForTimeout(500) + + const response1 = await pollUntilHeadersMatch( + new URL(pagePath, next16TagRevalidation.url).href, + { + headersToMatch: { + // either first time hitting this route or we invalidated + // just CDN node in earlier step + // we will invoke function and see Next cache hit status + // in the response if it was prerendered at build time + // or regenerated in previous attempt to run this test + 'cache-status': [ + /"Netlify Edge"; fwd=(miss|stale)/m, + prerendered ? /"Next.js"; hit/m : /"Next.js"; (hit|fwd=miss)/m, + ], + }, + headersNotMatchedMessage: + 'First request to tested page should be a miss or stale on the Edge and hit in Next.js', + }, + ) + const headers1 = response1?.headers() || {} + expect(response1?.status()).toBe(200) + expect(headers1['x-nextjs-cache']).toBeUndefined() + expect(headers1['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable') + + const date1 = await page.getByTestId('date-now').textContent() + + const h1 = await page.locator('h1').textContent() + expect(h1).toBe(expectedH1Content) + + const response2 = await pollUntilHeadersMatch( + new URL(pagePath, next16TagRevalidation.url).href, + { + headersToMatch: { + // we are hitting the same page again and we most likely will see + // CDN hit (in this case Next reported cache status is omitted + // as it didn't actually take place in handling this request) + // or we will see CDN miss because different CDN node handled request + 'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m, + }, + headersNotMatchedMessage: + 'Second request to tested page should most likely be a hit on the Edge (optionally miss or stale if different CDN node)', + }, + ) + const headers2 = response2?.headers() || {} + expect(response2?.status()).toBe(200) + expect(headers2['x-nextjs-cache']).toBeUndefined() + if (!headers2['cache-status'].includes('"Netlify Edge"; hit')) { + // if we missed CDN cache, we will see Next cache hit status + // as we reuse cached response + expect(headers2['cache-status']).toMatch(/"Next.js"; hit/m) + } + expect(headers2['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable') + + // the page is cached + const date2 = await page.getByTestId('date-now').textContent() + expect(date2).toBe(date1) + + await page.getByTestId('update-tag-button').click() + + await expect(page.getByTestId('date-now')).not.toHaveText(date2!, { timeout: 15_000 }) + }) + }) + } + }) +} diff --git a/tests/fixtures/next-16-tag-revalidation/app/api/purge-cdn/route.ts b/tests/fixtures/next-16-tag-revalidation/app/api/purge-cdn/route.ts new file mode 100644 index 0000000000..1f2b9d521f --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/api/purge-cdn/route.ts @@ -0,0 +1,40 @@ +import { purgeCache } from '@netlify/functions' +import { NextRequest, NextResponse } from 'next/server' + +export async function GET(request: NextRequest) { + const url = new URL(request.url) + const pathToPurge = url.searchParams.get('path') + + if (!pathToPurge) { + return NextResponse.json( + { + status: 'error', + error: 'missing "path" query parameter', + }, + { status: 400 }, + ) + } + try { + await purgeCache({ tags: [`_N_T_${encodeURI(pathToPurge)}`] }) + return NextResponse.json( + { + status: 'ok', + }, + { + status: 200, + }, + ) + } catch (error) { + return NextResponse.json( + { + status: 'error', + error: error.toString(), + }, + { + status: 500, + }, + ) + } +} + +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/next-16-tag-revalidation/app/api/revalidate-tag/route.ts b/tests/fixtures/next-16-tag-revalidation/app/api/revalidate-tag/route.ts new file mode 100644 index 0000000000..9984ed5362 --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/api/revalidate-tag/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server' +import { revalidateTag } from 'next/cache' + +export async function GET(request: NextRequest) { + const url = new URL(request.url) + const tagToRevalidate = url.searchParams.get('tag') ?? 'collection' + + let profile: Parameters[1] | undefined | null + if (url.searchParams.has('profile')) { + profile = url.searchParams.get('profile') + } else if (url.searchParams.has('expire')) { + profile = { + expire: parseInt(url.searchParams.get('expire')), + } + } + + if (profile) { + console.log(`Revalidating tag: ${tagToRevalidate}, profile: ${JSON.stringify(profile)}`) + + revalidateTag(tagToRevalidate, profile) + return NextResponse.json({ revalidated: true, now: new Date().toISOString() }) + } else { + return NextResponse.json({ error: 'Missing profile or expire query param' }, { status: 400 }) + } +} + +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/next-16-tag-revalidation/app/layout.js b/tests/fixtures/next-16-tag-revalidation/app/layout.js new file mode 100644 index 0000000000..c7729550fe --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/layout.js @@ -0,0 +1,12 @@ +export const metadata = { + title: 'Revalidate fetch', + description: 'Description for Simple Next App', +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-explicit-inline-expire/product-static/page.js b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-explicit-inline-expire/product-static/page.js new file mode 100644 index 0000000000..4d944fbe0a --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-explicit-inline-expire/product-static/page.js @@ -0,0 +1,35 @@ +import { unstable_cache } from 'next/cache' + +const slug = 'product-static' +const tag = `revalidate-tag-explicit-inline-expire-${slug}` + +const Product = async () => { + // using unstable_cache here to add custom tags without using fetch + const getData = unstable_cache( + async () => { + // add artificial delay to test that background revalidation is not interrupted + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + slug, + timestamp: new Date().toISOString(), + } + }, + [slug], + { + tags: [tag], + }, + ) + + const data = await getData() + + return ( +
+

Product {decodeURIComponent(slug)}

+ {data.timestamp} +
+ ) +} + +export const dynamic = 'force-static' + +export default Product diff --git a/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-explicit-inline-expire/product/[slug]/page.js b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-explicit-inline-expire/product/[slug]/page.js new file mode 100644 index 0000000000..525073afdb --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-explicit-inline-expire/product/[slug]/page.js @@ -0,0 +1,44 @@ +import { unstable_cache } from 'next/cache' + +const Product = async ({ params }) => { + const { slug } = await params + + const tag = `revalidate-tag-explicit-inline-expire-${slug}` + + // using unstable_cache here to add custom tags without using fetch + const getData = unstable_cache( + async () => { + // add artificial delay to test that background revalidation is not interrupted + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + slug, + timestamp: new Date().toISOString(), + } + }, + [slug], + { + tags: [tag], + }, + ) + + const data = await getData() + + return ( +
+

Product {decodeURIComponent(slug)}

+ {data.timestamp} +
+ ) +} + +export async function generateStaticParams() { + return [ + { + slug: 'prerendered', + }, + ] +} + +export const dynamic = 'force-static' + +export default Product diff --git a/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-string-profile/product-static/page.js b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-string-profile/product-static/page.js new file mode 100644 index 0000000000..e172b8e561 --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-string-profile/product-static/page.js @@ -0,0 +1,35 @@ +import { unstable_cache } from 'next/cache' + +const slug = 'product-static' +const tag = `revalidate-tag-string-profile-${slug}` + +const Product = async () => { + // using unstable_cache here to add custom tags without using fetch + const getData = unstable_cache( + async () => { + // add artificial delay to test that background revalidation is not interrupted + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + slug, + timestamp: new Date().toISOString(), + } + }, + [slug], + { + tags: [tag], + }, + ) + + const data = await getData() + + return ( +
+

Product {decodeURIComponent(slug)}

+ {data.timestamp} +
+ ) +} + +export const dynamic = 'force-static' + +export default Product diff --git a/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-string-profile/product/[slug]/page.js b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-string-profile/product/[slug]/page.js new file mode 100644 index 0000000000..8a1b3f06f1 --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/revalidate-tag-string-profile/product/[slug]/page.js @@ -0,0 +1,44 @@ +import { unstable_cache } from 'next/cache' + +const Product = async ({ params }) => { + const { slug } = await params + + const tag = `revalidate-tag-string-profile-${slug}` + + // using unstable_cache here to add custom tags without using fetch + const getData = unstable_cache( + async () => { + // add artificial delay to test that background revalidation is not interrupted + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + slug, + timestamp: new Date().toISOString(), + } + }, + [slug], + { + tags: [tag], + }, + ) + + const data = await getData() + + return ( +
+

Product {decodeURIComponent(slug)}

+ {data.timestamp} +
+ ) +} + +export async function generateStaticParams() { + return [ + { + slug: 'prerendered', + }, + ] +} + +export const dynamic = 'force-static' + +export default Product diff --git a/tests/fixtures/next-16-tag-revalidation/app/update-tag/product-static/page.js b/tests/fixtures/next-16-tag-revalidation/app/update-tag/product-static/page.js new file mode 100644 index 0000000000..2c9ac266ca --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/update-tag/product-static/page.js @@ -0,0 +1,46 @@ +import { unstable_cache, updateTag } from 'next/cache' + +const slug = 'product-static' +const tag = `update-tag-${slug}` + +const Product = async () => { + // using unstable_cache here to add custom tags without using fetch + const getData = unstable_cache( + async () => { + // add artificial delay to test that background revalidation is not interrupted + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + slug, + timestamp: new Date().toISOString(), + } + }, + [slug], + { + tags: [tag], + }, + ) + + const data = await getData() + + return ( +
+

Product {decodeURIComponent(slug)}

+ {data.timestamp} +
+ +
+
+ ) +} + +export const dynamic = 'force-static' + +export default Product diff --git a/tests/fixtures/next-16-tag-revalidation/app/update-tag/product/[slug]/page.js b/tests/fixtures/next-16-tag-revalidation/app/update-tag/product/[slug]/page.js new file mode 100644 index 0000000000..8067165dc4 --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/app/update-tag/product/[slug]/page.js @@ -0,0 +1,55 @@ +import { unstable_cache, updateTag } from 'next/cache' + +const Product = async ({ params }) => { + const { slug } = await params + + const tag = `update-tag-${slug}` + + // using unstable_cache here to add custom tags without using fetch + const getData = unstable_cache( + async () => { + // add artificial delay to test that background revalidation is not interrupted + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + slug, + timestamp: new Date().toISOString(), + } + }, + [slug], + { + tags: [tag], + }, + ) + + const data = await getData() + + return ( +
+

Product {decodeURIComponent(slug)}

+ {data.timestamp} +
+ +
+
+ ) +} + +export async function generateStaticParams() { + return [ + { + slug: 'prerendered', + }, + ] +} + +export const dynamic = 'force-static' + +export default Product diff --git a/tests/fixtures/next-16-tag-revalidation/next-env.d.ts b/tests/fixtures/next-16-tag-revalidation/next-env.d.ts new file mode 100644 index 0000000000..c4e7c0ebef --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import './.next/types/routes.d.ts' + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/tests/fixtures/next-16-tag-revalidation/next.config.js b/tests/fixtures/next-16-tag-revalidation/next.config.js new file mode 100644 index 0000000000..14adefcbd9 --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/next.config.js @@ -0,0 +1,16 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + outputFileTracingRoot: __dirname, + experimental: { + cacheLife: { + testCacheLife: { + stale: 0, + revalidate: 365 * 60 * 60 * 24, // 1 year + expire: 5, // 5 seconds to test expiration + }, + }, + }, +} + +module.exports = nextConfig diff --git a/tests/fixtures/next-16-tag-revalidation/package.json b/tests/fixtures/next-16-tag-revalidation/package.json new file mode 100644 index 0000000000..68821f2441 --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/package.json @@ -0,0 +1,26 @@ +{ + "name": "next-16-tag-revalidation", + "description": "Testing updated tags/caching APIs for next@16", + "version": "0.1.0", + "private": true, + "scripts": { + "postinstall": "next build", + "dev": "next dev", + "build": "next build" + }, + "dependencies": { + "@netlify/functions": "^2.7.0", + "@types/node": "^24.7.2", + "@types/react": "^19.2.2", + "next": "^16.0.0-beta.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "semver": "^7.7.2", + "typescript": "^5.9.3" + }, + "test": { + "dependencies": { + "next": ">=16.0.0-alpha.0" + } + } +} diff --git a/tests/fixtures/next-16-tag-revalidation/tsconfig.json b/tests/fixtures/next-16-tag-revalidation/tsconfig.json new file mode 100644 index 0000000000..41369d3b36 --- /dev/null +++ b/tests/fixtures/next-16-tag-revalidation/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.mts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/tests/prepare.mjs b/tests/prepare.mjs index 164f702b17..c6b2ba0ddc 100644 --- a/tests/prepare.mjs +++ b/tests/prepare.mjs @@ -31,6 +31,7 @@ const e2eOnlyFixtures = new Set([ // see https://github.com/opennextjs/opennextjs-netlify/actions/runs/13268839161/job/37043172448?pr=2749#step:12:78 'middleware-og', 'middleware-single-matcher', + 'next-16-tag-revalidation', 'nx-integrated', 'turborepo', 'turborepo-npm', diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index 639a996aeb..7e23bb768f 100644 --- a/tests/utils/create-e2e-fixture.ts +++ b/tests/utils/create-e2e-fixture.ts @@ -393,6 +393,7 @@ export const fixtureFactories = { buildCommand: 'turbo build --filter page-router', }), serverComponents: () => createE2EFixture('server-components'), + next16TagRevalidation: () => createE2EFixture('next-16-tag-revalidation'), nxIntegrated: () => createE2EFixture('nx-integrated', { packagePath: 'apps/next-app', From a4b1046baa6d81aae5bcee6abad8c3dbf5d9b562 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 10 Oct 2025 17:53:18 +0200 Subject: [PATCH 2/2] feat: support revaldiateTag with SWR behavior --- src/run/handlers/cache.cts | 38 ++++++-- src/run/handlers/tags-handler.cts | 132 ++++++++++++++++++-------- src/run/handlers/use-cache-handler.ts | 10 +- src/shared/blob-types.cts | 19 +++- src/shared/blob-types.test.ts | 4 +- 5 files changed, 144 insertions(+), 59 deletions(-) diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index 1baa0c3957..359b5c96d3 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -25,7 +25,12 @@ import { } from '../storage/storage.cjs' import { getLogger, getRequestContext } from './request-context.cjs' -import { isAnyTagStale, markTagsAsStaleAndPurgeEdgeCache, purgeEdgeCache } from './tags-handler.cjs' +import { + isAnyTagStaleOrExpired, + markTagsAsStaleAndPurgeEdgeCache, + purgeEdgeCache, + type TagStaleOrExpired, +} from './tags-handler.cjs' import { getTracer, recordWarning } from './tracer.cjs' let memoizedPrerenderManifest: PrerenderManifest @@ -290,19 +295,26 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { return null } - const staleByTags = await this.checkCacheEntryStaleByTags( + const { stale: staleByTags, expired: expiredByTags } = await this.checkCacheEntryStaleByTags( blob, context.tags, context.softTags, ) - if (staleByTags) { - span.addEvent('Stale', { staleByTags, key, ttl }) + if (expiredByTags) { + span.addEvent('Expired', { expiredByTags, key, ttl }) return null } this.captureResponseCacheLastModified(blob, key, span) + if (staleByTags) { + span.addEvent('Stale', { staleByTags, key, ttl }) + // note that we modify this after we capture last modified to ensure that Age is correct + // but we still let Next.js know that entry is stale + blob.lastModified = -1 // indicate that the entry is stale + } + // Next sets a kind/kindHint and fetchUrl for data requests, however fetchUrl was found to be most reliable across versions const isDataRequest = Boolean(context.fetchUrl) if (!isDataRequest) { @@ -477,8 +489,8 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { }) } - async revalidateTag(tagOrTags: string | string[]) { - return markTagsAsStaleAndPurgeEdgeCache(tagOrTags) + async revalidateTag(tagOrTags: string | string[], durations?: { expire?: number }) { + return markTagsAsStaleAndPurgeEdgeCache(tagOrTags, durations) } resetRequestCache() { @@ -493,7 +505,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { cacheEntry: NetlifyCacheHandlerValue, tags: string[] = [], softTags: string[] = [], - ) { + ): TagStaleOrExpired | Promise { let cacheTags: string[] = [] if (cacheEntry.value?.kind === 'FETCH') { @@ -508,7 +520,10 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { cacheTags = (cacheEntry.value.headers?.[NEXT_CACHE_TAGS_HEADER] as string)?.split(/,|%2c/gi) || [] } else { - return false + return { + stale: false, + expired: false, + } } // 1. Check if revalidateTags array passed from Next.js contains any of cacheEntry tags @@ -516,14 +531,17 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { // TODO: test for this case for (const tag of this.revalidatedTags) { if (cacheTags.includes(tag)) { - return true + return { + stale: true, + expired: true, + } } } } // 2. If any in-memory tags don't indicate that any of tags was invalidated // we will check blob store. - return isAnyTagStale(cacheTags, cacheEntry.lastModified) + return isAnyTagStaleOrExpired(cacheTags, cacheEntry.lastModified) } } diff --git a/src/run/handlers/tags-handler.cts b/src/run/handlers/tags-handler.cts index 47b86d8562..b174fa6102 100644 --- a/src/run/handlers/tags-handler.cts +++ b/src/run/handlers/tags-handler.cts @@ -11,47 +11,55 @@ import { getLogger, getRequestContext } from './request-context.cjs' const purgeCacheUserAgent = `${nextRuntimePkgName}@${nextRuntimePkgVersion}` -/** - * Get timestamp of the last revalidation for a tag - */ -async function getTagRevalidatedAt( +async function getTagManifest( tag: string, cacheStore: MemoizedKeyValueStoreBackedByRegionalBlobStore, -): Promise { +): Promise { const tagManifest = await cacheStore.get(tag, 'tagManifest.get') if (!tagManifest) { return null } - return tagManifest.revalidatedAt + return tagManifest } /** * Get the most recent revalidation timestamp for a list of tags */ -export async function getMostRecentTagRevalidationTimestamp(tags: string[]) { +export async function getMostRecentTagExpirationTimestamp(tags: string[]) { if (tags.length === 0) { return 0 } const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' }) - const timestampsOrNulls = await Promise.all( - tags.map((tag) => getTagRevalidatedAt(tag, cacheStore)), - ) + const timestampsOrNulls = await Promise.all(tags.map((tag) => getTagManifest(tag, cacheStore))) - const timestamps = timestampsOrNulls.filter((timestamp) => timestamp !== null) - if (timestamps.length === 0) { + const expirationTimestamps = timestampsOrNulls + .filter((timestamp) => timestamp !== null) + .map((manifest) => manifest.expiredAt) + if (expirationTimestamps.length === 0) { return 0 } - return Math.max(...timestamps) + return Math.max(...expirationTimestamps) } +export type TagStaleOrExpired = + // FRESH + | { stale: false; expired: false } + // STALE + | { stale: true; expired: false; expireAt: number } + // EXPIRED (should be treated similarly to MISS) + | { stale: true; expired: true } + /** - * Check if any of the tags were invalidated since the given timestamp + * Check if any of the tags expired since the given timestamp */ -export function isAnyTagStale(tags: string[], timestamp: number): Promise { +export function isAnyTagStaleOrExpired( + tags: string[], + timestamp: number, +): Promise { if (tags.length === 0 || !timestamp) { - return Promise.resolve(false) + return Promise.resolve({ stale: false, expired: false }) } const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' }) @@ -60,37 +68,74 @@ export function isAnyTagStale(tags: string[], timestamp: number): Promise((resolve, reject) => { - const tagManifestPromises: Promise[] = [] + // "Worst case" scenario is none of tag was expired in which case we need to wait + // for all blob store checks to finish before we can be certain that no tag is expired. + return new Promise((resolve, reject) => { + const tagManifestPromises: Promise[] = [] for (const tag of tags) { - const lastRevalidationTimestampPromise = getTagRevalidatedAt(tag, cacheStore) + const tagManifestPromise = getTagManifest(tag, cacheStore) tagManifestPromises.push( - lastRevalidationTimestampPromise.then((lastRevalidationTimestamp) => { - if (!lastRevalidationTimestamp) { + tagManifestPromise.then((tagManifest) => { + if (!tagManifest) { // tag was never revalidated - return false + return { stale: false, expired: false } + } + const stale = tagManifest.staleAt >= timestamp + const expired = tagManifest.expiredAt >= timestamp && tagManifest.expiredAt <= Date.now() + + if (expired && stale) { + const expiredResult: TagStaleOrExpired = { + stale, + expired, + } + // resolve outer promise immediately if any of the tags is expired + resolve(expiredResult) + return expiredResult } - const isStale = lastRevalidationTimestamp >= timestamp - if (isStale) { - // resolve outer promise immediately if any of the tags is stale - resolve(true) - return true + + if (stale) { + const staleResult: TagStaleOrExpired = { + stale, + expired, + expireAt: tagManifest.expiredAt, + } + return staleResult } - return false + return { stale: false, expired: false } }), ) } - // make sure we resolve promise after all blobs are checked (if we didn't resolve as stale yet) + // make sure we resolve promise after all blobs are checked (if we didn't resolve as expired yet) Promise.all(tagManifestPromises) - .then((tagManifestAreStale) => { - resolve(tagManifestAreStale.some((tagIsStale) => tagIsStale)) + .then((tagManifestsAreStaleOrExpired) => { + let result: TagStaleOrExpired = { stale: false, expired: false } + + for (const tagResult of tagManifestsAreStaleOrExpired) { + if (tagResult.expired) { + // if any of the tags is expired, the whole thing is expired + result = tagResult + break + } + + if (tagResult.stale) { + result = { + stale: true, + expired: false, + expireAt: + // make sure to use expireAt that is lowest of all tags + result.stale && !result.expired && typeof result.expireAt === 'number' + ? Math.min(result.expireAt, tagResult.expireAt) + : tagResult.expireAt, + } + } + } + + resolve(result) }) .catch(reject) }) @@ -122,15 +167,21 @@ export function purgeEdgeCache(tagOrTags: string | string[]): Promise { }) } -async function doRevalidateTagAndPurgeEdgeCache(tags: string[]): Promise { - getLogger().withFields({ tags }).debug('doRevalidateTagAndPurgeEdgeCache') +async function doRevalidateTagAndPurgeEdgeCache( + tags: string[], + durations?: { expire?: number }, +): Promise { + getLogger().withFields({ tags, durations }).debug('doRevalidateTagAndPurgeEdgeCache') if (tags.length === 0) { return } + const now = Date.now() + const tagManifest: TagManifest = { - revalidatedAt: Date.now(), + staleAt: now, + expiredAt: now + (durations?.expire ? durations.expire * 1000 : 0), } const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' }) @@ -148,10 +199,13 @@ async function doRevalidateTagAndPurgeEdgeCache(tags: string[]): Promise { await purgeEdgeCache(tags) } -export function markTagsAsStaleAndPurgeEdgeCache(tagOrTags: string | string[]) { +export function markTagsAsStaleAndPurgeEdgeCache( + tagOrTags: string | string[], + durations?: { expire?: number }, +) { const tags = getCacheTagsFromTagOrTags(tagOrTags) - const revalidateTagPromise = doRevalidateTagAndPurgeEdgeCache(tags) + const revalidateTagPromise = doRevalidateTagAndPurgeEdgeCache(tags, durations) const requestContext = getRequestContext() if (requestContext) { diff --git a/src/run/handlers/use-cache-handler.ts b/src/run/handlers/use-cache-handler.ts index b2f6f5f2b9..510a2087b5 100644 --- a/src/run/handlers/use-cache-handler.ts +++ b/src/run/handlers/use-cache-handler.ts @@ -10,8 +10,8 @@ import type { import { getLogger } from './request-context.cjs' import { - getMostRecentTagRevalidationTimestamp, - isAnyTagStale, + getMostRecentTagExpirationTimestamp, + isAnyTagStaleOrExpired, markTagsAsStaleAndPurgeEdgeCache, } from './tags-handler.cjs' import { getTracer } from './tracer.cjs' @@ -127,7 +127,9 @@ export const NetlifyDefaultUseCacheHandler = { return undefined } - if (await isAnyTagStale(entry.tags, entry.timestamp)) { + const { stale } = await isAnyTagStaleOrExpired(entry.tags, entry.timestamp) + + if (stale) { getLogger() .withFields({ cacheKey, ttl, status: 'STALE BY TAG' }) .debug(`[NetlifyDefaultUseCacheHandler] get result`) @@ -229,7 +231,7 @@ export const NetlifyDefaultUseCacheHandler = { tags, }) - const expiration = await getMostRecentTagRevalidationTimestamp(tags) + const expiration = await getMostRecentTagExpirationTimestamp(tags) getLogger() .withFields({ tags, expiration }) diff --git a/src/shared/blob-types.cts b/src/shared/blob-types.cts index c7b113a262..0f0200e387 100644 --- a/src/shared/blob-types.cts +++ b/src/shared/blob-types.cts @@ -1,6 +1,15 @@ import { type NetlifyCacheHandlerValue } from './cache-types.cjs' -export type TagManifest = { revalidatedAt: number } +export type TagManifest = { + /** + * Timestamp when tag was revalidated. Used to determine if a tag is stale. + */ + staleAt: number + /** + * Timestamp when tagged cache entry should no longer serve stale content. + */ + expiredAt: number +} export type HtmlBlob = { html: string @@ -13,9 +22,11 @@ export const isTagManifest = (value: BlobType): value is TagManifest => { return ( typeof value === 'object' && value !== null && - 'revalidatedAt' in value && - typeof value.revalidatedAt === 'number' && - Object.keys(value).length === 1 + 'staleAt' in value && + typeof value.staleAt === 'number' && + 'expiredAt' in value && + typeof value.expiredAt === 'number' && + Object.keys(value).length === 2 ) } diff --git a/src/shared/blob-types.test.ts b/src/shared/blob-types.test.ts index 16c0a5c5f9..2bff65f43b 100644 --- a/src/shared/blob-types.test.ts +++ b/src/shared/blob-types.test.ts @@ -4,7 +4,7 @@ import { BlobType, HtmlBlob, isHtmlBlob, isTagManifest, TagManifest } from './bl describe('isTagManifest', () => { it(`returns true for TagManifest instance`, () => { - const value: TagManifest = { revalidatedAt: 0 } + const value: TagManifest = { staleAt: 0, expiredAt: 0 } expect(isTagManifest(value)).toBe(true) }) @@ -21,7 +21,7 @@ describe('isHtmlBlob', () => { }) it(`returns false for non-HtmlBlob instance`, () => { - const value: BlobType = { revalidatedAt: 0 } + const value: BlobType = { staleAt: 0, expiredAt: 0 } expect(isHtmlBlob(value)).toBe(false) }) })