diff --git a/packages/next/server/api-utils/index.ts b/packages/next/server/api-utils/index.ts index 59c6a9bc0dfc0d5..abb8b396f67ddea 100644 --- a/packages/next/server/api-utils/index.ts +++ b/packages/next/server/api-utils/index.ts @@ -71,12 +71,22 @@ export function redirect( } export const PRERENDER_REVALIDATE_HEADER = 'x-prerender-revalidate' +export const PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER = + 'x-prerender-revalidate-if-generated' export function checkIsManualRevalidate( req: IncomingMessage | BaseNextRequest, previewProps: __ApiPreviewProps -): boolean { - return req.headers[PRERENDER_REVALIDATE_HEADER] === previewProps.previewModeId +): { + isManualRevalidate: boolean + revalidateOnlyGenerated: boolean +} { + return { + isManualRevalidate: + req.headers[PRERENDER_REVALIDATE_HEADER] === previewProps.previewModeId, + revalidateOnlyGenerated: + !!req.headers[PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER], + } } export const COOKIE_NAME_PRERENDER_BYPASS = `__prerender_bypass` diff --git a/packages/next/server/api-utils/node.ts b/packages/next/server/api-utils/node.ts index 68f928a23755259..310c0afd12f4246 100644 --- a/packages/next/server/api-utils/node.ts +++ b/packages/next/server/api-utils/node.ts @@ -1,7 +1,10 @@ import type { IncomingMessage, ServerResponse } from 'http' import type { NextApiRequest, NextApiResponse } from '../../shared/lib/utils' import type { PageConfig } from 'next/types' -import type { __ApiPreviewProps } from '.' +import { + PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER, + __ApiPreviewProps, +} from '.' import type { BaseNextRequest, BaseNextResponse } from '../base-http' import type { CookieSerializeOptions } from 'next/dist/compiled/cookie' import type { PreviewData } from 'next/types' @@ -233,8 +236,12 @@ export async function apiResolver( apiRes.setPreviewData = (data, options = {}) => setPreviewData(apiRes, data, Object.assign({}, apiContext, options)) apiRes.clearPreviewData = () => clearPreviewData(apiRes) - apiRes.unstable_revalidate = (urlPath: string) => - unstable_revalidate(urlPath, req, apiContext) + apiRes.unstable_revalidate = ( + urlPath: string, + opts?: { + unstable_onlyGenerated?: boolean + } + ) => unstable_revalidate(urlPath, opts || {}, req, apiContext) const resolver = interopDefault(resolverModule) let wasPiped = false @@ -279,6 +286,9 @@ export async function apiResolver( async function unstable_revalidate( urlPath: string, + opts: { + unstable_onlyGenerated?: boolean + }, req: IncomingMessage, context: ApiContext ) { @@ -287,12 +297,20 @@ async function unstable_revalidate( `Invalid urlPath provided to revalidate(), must be a path e.g. /blog/post-1, received ${urlPath}` ) } + const revalidateHeaders = { + [PRERENDER_REVALIDATE_HEADER]: context.previewModeId, + ...(opts.unstable_onlyGenerated + ? { + [PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER]: '1', + } + : {}), + } try { if (context.trustHostHeader) { const res = await fetch(`https://${req.headers.host}${urlPath}`, { headers: { - [PRERENDER_REVALIDATE_HEADER]: context.previewModeId, + ...revalidateHeaders, cookie: req.headers.cookie || '', }, }) @@ -302,7 +320,10 @@ async function unstable_revalidate( const cacheHeader = res.headers.get('x-vercel-cache') || res.headers.get('x-nextjs-cache') - if (cacheHeader?.toUpperCase() !== 'REVALIDATED') { + if ( + cacheHeader?.toUpperCase() !== 'REVALIDATED' && + !(res.status === 404 && opts.unstable_onlyGenerated) + ) { throw new Error(`Invalid response ${res.status}`) } } else if (context.revalidate) { @@ -310,18 +331,15 @@ async function unstable_revalidate( req: mockReq, res: mockRes, streamPromise, - } = mockRequest( - urlPath, - { - [PRERENDER_REVALIDATE_HEADER]: context.previewModeId, - }, - 'GET' - ) + } = mockRequest(urlPath, revalidateHeaders, 'GET') await context.revalidate(mockReq, mockRes) await streamPromise - if (mockRes.getHeader('x-nextjs-cache') !== 'REVALIDATED') { - throw new Error(`Invalid response ${mockRes.status}`) + if ( + mockRes.getHeader('x-nextjs-cache') !== 'REVALIDATED' && + !(mockRes.statusCode === 404 && opts.unstable_onlyGenerated) + ) { + throw new Error(`Invalid response ${mockRes.statusCode}`) } } else { throw new Error( diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index 7d439aa7b61be09..94d7ae19d9f5177 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -1,4 +1,4 @@ -import type { __ApiPreviewProps } from './api-utils' +import { __ApiPreviewProps } from './api-utils' import type { CustomRoutes } from '../lib/load-custom-routes' import type { DomainLocale } from './config' import type { DynamicRoutes, PageChecker, Params, Route } from './router' @@ -1228,12 +1228,11 @@ export default abstract class Server { } let isManualRevalidate = false + let revalidateOnlyGenerated = false if (isSSG) { - isManualRevalidate = checkIsManualRevalidate( - req, - this.renderOpts.previewProps - ) + ;({ isManualRevalidate, revalidateOnlyGenerated } = + checkIsManualRevalidate(req, this.renderOpts.previewProps)) } // Compute the iSSG cache key. We use the rewroteUrl since @@ -1448,6 +1447,13 @@ export default abstract class Server { fallbackMode = 'blocking' } + // skip manual revalidate if cache is not present and + // revalidate-if-generated is set + if (isManualRevalidate && revalidateOnlyGenerated && !hadCache) { + await this.render404(req, res) + return null + } + // only allow manual revalidate for fallback: true/blocking // or for prerendered fallback: false paths if (isManualRevalidate && (fallbackMode !== false || hadCache)) { @@ -1545,7 +1551,7 @@ export default abstract class Server { ) if (!cacheEntry) { - if (ssgCacheKey) { + if (ssgCacheKey && !(isManualRevalidate && revalidateOnlyGenerated)) { // A cache entry might not be generated if a response is written // in `getInitialProps` or `getServerSideProps`, but those shouldn't // have a cache key. If we do have a cache key but we don't end up diff --git a/packages/next/shared/lib/utils.ts b/packages/next/shared/lib/utils.ts index 3d21f021804eb60..8bafaf0187890a4 100644 --- a/packages/next/shared/lib/utils.ts +++ b/packages/next/shared/lib/utils.ts @@ -253,7 +253,12 @@ export type NextApiResponse = ServerResponse & { ) => NextApiResponse clearPreviewData: () => NextApiResponse - unstable_revalidate: (urlPath: string) => Promise + unstable_revalidate: ( + urlPath: string, + opts?: { + unstable_onlyGenerated?: boolean + } + ) => Promise } /** diff --git a/test/e2e/prerender.test.ts b/test/e2e/prerender.test.ts index 018fe597589d6cc..b1514a54b97127e 100644 --- a/test/e2e/prerender.test.ts +++ b/test/e2e/prerender.test.ts @@ -2008,6 +2008,79 @@ describe('Prerender', () => { expect(res4.headers.get('x-nextjs-cache')).toMatch(/(HIT|STALE)/) }) + it('should not manual revalidate for fallback: blocking with onlyGenerated if not generated', async () => { + const res = await fetchViaHTTP( + next.url, + '/api/manual-revalidate', + { + pathname: '/blocking-fallback/test-if-generated-1', + onlyGenerated: '1', + }, + { redirect: 'manual' } + ) + + expect(res.status).toBe(200) + const revalidateData = await res.json() + expect(revalidateData.revalidated).toBe(true) + + expect(next.cliOutput).not.toContain( + `getStaticProps test-if-generated-1` + ) + + const res2 = await fetchViaHTTP( + next.url, + '/blocking-fallback/test-if-generated-1' + ) + expect(res2.headers.get('x-nextjs-cache')).toMatch(/(MISS)/) + expect(next.cliOutput).toContain(`getStaticProps test-if-generated-1`) + }) + + it('should manual revalidate for fallback: blocking with onlyGenerated if generated', async () => { + const res = await fetchViaHTTP( + next.url, + '/blocking-fallback/test-if-generated-2' + ) + const html = await res.text() + const $ = cheerio.load(html) + const initialTime = $('#time').text() + expect(res.headers.get('x-nextjs-cache')).toMatch(/MISS/) + + expect($('p').text()).toMatch(/Post:.*?test-if-generated-2/) + + const res2 = await fetchViaHTTP( + next.url, + '/blocking-fallback/test-if-generated-2' + ) + const html2 = await res2.text() + const $2 = cheerio.load(html2) + expect(res2.headers.get('x-nextjs-cache')).toMatch(/(HIT|STALE)/) + + expect(initialTime).toBe($2('#time').text()) + + const res3 = await fetchViaHTTP( + next.url, + '/api/manual-revalidate', + { + pathname: '/blocking-fallback/test-if-generated-2', + onlyGenerated: '1', + }, + { redirect: 'manual' } + ) + + expect(res3.status).toBe(200) + const revalidateData = await res3.json() + expect(revalidateData.revalidated).toBe(true) + + const res4 = await fetchViaHTTP( + next.url, + '/blocking-fallback/test-if-generated-2' + ) + const html4 = await res4.text() + const $4 = cheerio.load(html4) + expect($4('#time').text()).not.toBe(initialTime) + expect(res4.headers.get('x-nextjs-cache')).toMatch(/(HIT|STALE)/) + }) + it('should manual revalidate for revalidate: false', async () => { const html = await renderViaHTTP( next.url, diff --git a/test/e2e/prerender/pages/api/manual-revalidate.js b/test/e2e/prerender/pages/api/manual-revalidate.js index dc88de2b8497365..e87bf54a26eb017 100644 --- a/test/e2e/prerender/pages/api/manual-revalidate.js +++ b/test/e2e/prerender/pages/api/manual-revalidate.js @@ -3,7 +3,9 @@ export default async function handler(req, res) { // make sure to use trusted value for revalidating let revalidated = false try { - await res.unstable_revalidate(req.query.pathname) + await res.unstable_revalidate(req.query.pathname, { + unstable_onlyGenerated: !!req.query.onlyGenerated, + }) revalidated = true } catch (err) { console.error(err) diff --git a/test/e2e/prerender/pages/blocking-fallback/[slug].js b/test/e2e/prerender/pages/blocking-fallback/[slug].js index 5665ae6f008dfc3..cf6aec867a32033 100644 --- a/test/e2e/prerender/pages/blocking-fallback/[slug].js +++ b/test/e2e/prerender/pages/blocking-fallback/[slug].js @@ -28,6 +28,8 @@ export async function getStaticProps({ params }) { await new Promise((resolve) => setTimeout(resolve, 1000)) + console.log(`getStaticProps ${params.slug}`) + return { props: { params,