Update revalidate handling for app (#49062)

This updates revalidate handling for app and moves some exports from
`next/server` to `next/cache`

x-ref: [slack
thread](https://vercel.slack.com/archives/C042LHPJ1NX/p1682535961644979?thread_ts=1682368724.384619&cid=C042LHPJ1NX)

---------
This commit is contained in:
JJ Kasper 2023-05-02 08:19:02 -07:00 committed by GitHub
parent 02c5b5f6d6
commit abc74fb92e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 592 additions and 63 deletions

3
packages/next/cache.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache'
export { unstable_revalidatePath } from 'next/dist/server/web/spec-extension/unstable-revalidate-path'
export { unstable_revalidateTag } from 'next/dist/server/web/spec-extension/unstable-revalidate-tag'

19
packages/next/cache.js Normal file
View file

@ -0,0 +1,19 @@
const cacheExports = {
unstable_cache: require('next/dist/server/web/spec-extension/unstable-cache')
.unstable_cache,
unstable_revalidateTag:
require('next/dist/server/web/spec-extension/unstable-revalidate-tag')
.unstable_revalidateTag,
unstable_revalidatePath:
require('next/dist/server/web/spec-extension/unstable-revalidate-path')
.unstable_revalidatePath,
}
// https://nodejs.org/api/esm.html#commonjs-namespaces
// When importing CommonJS modules, the module.exports object is provided as the default export
module.exports = cacheExports
// make import { xxx } from 'next/server' work
exports.unstable_cache = cacheExports.unstable_cache
exports.unstable_revalidatePath = cacheExports.unstable_revalidatePath
exports.unstable_revalidateTag = cacheExports.unstable_revalidateTag

View file

@ -2,6 +2,7 @@
/// <reference path="./dist/styled-jsx/types/global.d.ts" />
/// <reference path="./amp.d.ts" />
/// <reference path="./app.d.ts" />
/// <reference path="./cache.d.ts" />
/// <reference path="./config.d.ts" />
/// <reference path="./document.d.ts" />
/// <reference path="./dynamic.d.ts" />

View file

@ -17,6 +17,8 @@
"client.js",
"client.d.ts",
"compat",
"cache.js",
"cache.d.ts",
"config.js",
"config.d.ts",
"constants.js",

View file

@ -13,6 +13,3 @@ export { userAgent } from 'next/dist/server/web/spec-extension/user-agent'
export { URLPattern } from 'next/dist/compiled/@edge-runtime/primitives/url'
export { ImageResponse } from 'next/dist/server/web/spec-extension/image-response'
export type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types'
export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache'
export { unstable_revalidatePath } from 'next/dist/server/web/spec-extension/unstable-revalidate-path'
export { unstable_revalidateTag } from 'next/dist/server/web/spec-extension/unstable-revalidate-tag'

View file

@ -5,14 +5,6 @@ const serverExports = {
.NextResponse,
ImageResponse: require('next/dist/server/web/spec-extension/image-response')
.ImageResponse,
unstable_cache: require('next/dist/server/web/spec-extension/unstable-cache')
.unstable_cache,
unstable_revalidateTag:
require('next/dist/server/web/spec-extension/unstable-revalidate-tag')
.unstable_revalidateTag,
unstable_revalidatePath:
require('next/dist/server/web/spec-extension/unstable-revalidate-path')
.unstable_revalidatePath,
userAgentFromString: require('next/dist/server/web/spec-extension/user-agent')
.userAgentFromString,
userAgent: require('next/dist/server/web/spec-extension/user-agent')
@ -32,9 +24,6 @@ module.exports = serverExports
exports.NextRequest = serverExports.NextRequest
exports.NextResponse = serverExports.NextResponse
exports.ImageResponse = serverExports.ImageResponse
exports.unstable_cache = serverExports.unstable_cache
exports.unstable_revalidatePath = serverExports.unstable_revalidatePath
exports.unstable_revalidateTag = serverExports.unstable_revalidateTag
exports.userAgentFromString = serverExports.userAgentFromString
exports.userAgent = serverExports.userAgent
exports.URLPattern = serverExports.URLPattern

View file

@ -2377,16 +2377,16 @@ export default async function build(
initialHeaders?: SsgRoute['initialHeaders']
} = {}
if (isRouteHandler) {
const exportRouteMeta =
exportConfig.initialPageMetaMap[route] || {}
const exportRouteMeta: {
status?: number
headers?: Record<string, string>
} = exportConfig.initialPageMetaMap[route] || {}
if (exportRouteMeta.status !== 200) {
routeMeta.initialStatus = exportRouteMeta.status
}
if (Object.keys(exportRouteMeta.headers).length) {
routeMeta.initialHeaders = exportRouteMeta.headers
}
if (exportRouteMeta.status !== 200) {
routeMeta.initialStatus = exportRouteMeta.status
}
if (Object.keys(exportRouteMeta.headers || {}).length) {
routeMeta.initialHeaders = exportRouteMeta.headers
}
finalPrerenderRoutes[route] = {

View file

@ -7,6 +7,7 @@ export interface StaticGenerationStore {
readonly pathname: string
readonly incrementalCache?: IncrementalCache
readonly isRevalidate?: boolean
readonly isMinimalMode?: boolean
readonly isOnDemandRevalidate?: boolean
readonly isPrerendering?: boolean
@ -17,6 +18,7 @@ export interface StaticGenerationStore {
| 'force-no-store'
| 'default-no-store'
| 'only-no-store'
revalidate?: false | number
forceStatic?: boolean
dynamicShouldError?: boolean
@ -26,6 +28,8 @@ export interface StaticGenerationStore {
dynamicUsageStack?: string
nextFetchId?: number
tags?: string[]
}
export type StaticGenerationAsyncStorage =

View file

@ -46,6 +46,7 @@ import { isAppRouteRoute } from '../lib/is-app-route-route'
import { toNodeHeaders } from '../server/web/utils'
import { RouteModuleLoader } from '../server/future/helpers/module-loader/route-module-loader'
import { NextRequestAdapter } from '../server/web/spec-extension/adapters/next-request'
import * as ciEnvironment from '../telemetry/ci-info'
const envConfig = require('../shared/lib/runtime-config')
@ -322,6 +323,12 @@ export default async function exportPage({
fontManifest: optimizeFonts ? requireFontManifest(distDir) : null,
locale: locale as string,
supportsDynamicHTML: false,
...(ciEnvironment.hasNextSupport
? {
isMinimalMode: true,
isRevalidate: true,
}
: {}),
}
}
@ -400,6 +407,12 @@ export default async function exportPage({
nextExport: true,
supportsDynamicHTML: false,
incrementalCache: curRenderOpts.incrementalCache,
...(ciEnvironment.hasNextSupport
? {
isMinimalMode: true,
isRevalidate: true,
}
: {}),
},
}
@ -429,6 +442,12 @@ export default async function exportPage({
results.fromBuildExportRevalidate = revalidate
const headers = toNodeHeaders(response.headers)
const cacheTags = (context.staticGenerationContext as any)
.fetchTags
if (cacheTags) {
headers['x-next-cache-tags'] = cacheTags
}
if (!headers['content-type'] && body.type) {
headers['content-type'] = body.type
@ -481,7 +500,26 @@ export default async function exportPage({
results.fromBuildExportRevalidate = revalidate
if (revalidate !== 0) {
const cacheTags = (curRenderOpts as any).fetchTags
const headers = cacheTags
? {
'x-next-cache-tags': cacheTags,
}
: undefined
if (ciEnvironment.hasNextSupport) {
if (cacheTags) {
results.fromBuildExportMeta = {
headers,
}
}
}
await promises.writeFile(htmlFilepath, html ?? '', 'utf8')
await promises.writeFile(
htmlFilepath.replace(/\.html$/, '.meta'),
JSON.stringify({ headers })
)
await promises.writeFile(
htmlFilepath.replace(/\.html$/, '.rsc'),
flightData

View file

@ -49,7 +49,7 @@ import {
getURLFromRedirectError,
isRedirectError,
} from '../../client/components/redirect'
import { patchFetch } from '../lib/patch-fetch'
import { addImplicitTags, patchFetch } from '../lib/patch-fetch'
import { AppRenderSpan } from '../lib/trace/constants'
import { getTracer } from '../lib/trace/tracer'
import { interopDefault } from './interop-default'
@ -1571,6 +1571,8 @@ export async function renderToHTMLOrFlight(
if (staticGenerationStore.pendingRevalidates) {
await Promise.all(staticGenerationStore.pendingRevalidates)
}
addImplicitTags(staticGenerationStore)
;(renderOpts as any).fetchTags = staticGenerationStore.tags?.join(',')
if (staticGenerationStore.isStaticGeneration) {
const htmlResult = await streamToBufferedResult(renderResult)

View file

@ -38,6 +38,11 @@ export abstract class BaseNextResponse<Destination = any> {
*/
abstract setHeader(name: string, value: string | string[]): this
/**
* Removes a header
*/
abstract removeHeader(name: string): this
/**
* Appends value for the given header name
*/

View file

@ -85,6 +85,11 @@ export class NodeNextResponse extends BaseNextResponse<Writable> {
return this
}
removeHeader(name: string): this {
this._res.removeHeader(name)
return this
}
getHeaderValues(name: string): string[] | undefined {
const values = this._res.getHeader(name)

View file

@ -64,6 +64,11 @@ export class WebNextResponse extends BaseNextResponse<WritableStream> {
return this
}
removeHeader(name: string): this {
this.headers.delete(name)
return this
}
getHeaderValues(name: string): string[] | undefined {
// https://developer.mozilla.org/en-US/docs/Web/API/Headers/get#example
return this.getHeader(name)

View file

@ -25,6 +25,7 @@ import {
} from '../shared/lib/utils'
import type { PreviewData, ServerRuntime } from 'next/types'
import type { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin'
import type { OutgoingHttpHeaders } from 'http2'
import type { BaseNextRequest, BaseNextResponse } from './base-http'
import type { PayloadOptions } from './send-payload'
import type { PrerenderManifest } from '../build'
@ -1586,8 +1587,6 @@ export default abstract class Server<ServerOptions extends Options = Options> {
| 'https',
})
let isRevalidate = false
const doRender: () => Promise<ResponseCacheEntry | null> = async () => {
// In development, we always want to generate dynamic HTML.
const supportsDynamicHTML =
@ -1605,6 +1604,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
staticGenerationContext: {
supportsDynamicHTML,
incrementalCache,
isRevalidate: isSSG,
},
}
@ -1612,6 +1612,8 @@ export default abstract class Server<ServerOptions extends Options = Options> {
// Handle the match and collect the response if it's a static response.
const response = await this.handlers.handle(match, req, context)
if (response) {
const cacheTags = (context.staticGenerationContext as any).fetchTags
// If the request is for a static response, we can cache it so long
// as it's not edge.
if (isSSG && process.env.NEXT_RUNTIME !== 'edge') {
@ -1619,6 +1621,11 @@ export default abstract class Server<ServerOptions extends Options = Options> {
// Copy the headers from the response.
const headers = toNodeHeaders(response.headers)
if (cacheTags) {
headers['x-next-cache-tags'] = cacheTags
}
if (!headers['content-type'] && blob.type) {
headers['content-type'] = blob.type
}
@ -1669,6 +1676,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
let isrRevalidate: number | false
let isNotFound: boolean | undefined
let isRedirect: boolean | undefined
let headers: OutgoingHttpHeaders | undefined
const origQuery = parseUrl(req.url || '', true).query
@ -1694,7 +1702,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
...(isAppPath && this.nextConfig.experimental.appDir
? {
incrementalCache,
isRevalidate: this.minimalMode || isRevalidate,
isRevalidate: isSSG,
}
: {}),
isDataReq,
@ -1717,6 +1725,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
supportsDynamicHTML,
isOnDemandRevalidate,
isMinimalMode: this.minimalMode,
}
const renderResult = await this.renderHTML(
@ -1736,6 +1745,14 @@ export default abstract class Server<ServerOptions extends Options = Options> {
isNotFound = renderResultMeta.isNotFound
isRedirect = renderResultMeta.isRedirect
const cacheTags = (renderOpts as any).fetchTags
if (cacheTags) {
headers = {
'x-next-cache-tags': cacheTags,
}
}
// we don't throw static to dynamic errors in dev as isSSG
// is a best guess in dev since we don't have the prerender pass
// to know whether the path is actually static or not
@ -1771,7 +1788,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
if (body.isNull()) {
return null
}
value = { kind: 'PAGE', html: body, pageData }
value = { kind: 'PAGE', html: body, pageData, headers }
}
return { revalidate: isrRevalidate, value }
}
@ -1783,10 +1800,6 @@ export default abstract class Server<ServerOptions extends Options = Options> {
const isDynamicPathname = isDynamicRoute(pathname)
const didRespond = hasResolved || res.sent
if (hadCache) {
isRevalidate = true
}
if (!staticPaths) {
;({ staticPaths, fallbackMode } = hasStaticPaths
? await this.getStaticPaths({
@ -1815,6 +1828,10 @@ export default abstract class Server<ServerOptions extends Options = Options> {
return null
}
if (hadCache?.isStale === -1) {
isOnDemandRevalidate = true
}
// only allow on-demand revalidate for fallback: true/blocking
// or for prerendered fallback: false paths
if (isOnDemandRevalidate && (fallbackMode !== false || hadCache)) {
@ -1990,17 +2007,33 @@ export default abstract class Server<ServerOptions extends Options = Options> {
} else if (cachedData.kind === 'IMAGE') {
throw new Error('invariant SSG should not return an image cache value')
} else if (cachedData.kind === 'ROUTE') {
const headers = { ...cachedData.headers }
if (!(this.minimalMode && isSSG)) {
delete headers['x-next-cache-tags']
}
await sendResponse(
req,
res,
new Response(cachedData.body, {
headers: fromNodeHeaders(cachedData.headers),
headers: fromNodeHeaders(headers),
status: cachedData.status || 200,
})
)
return null
} else {
if (isAppPath) {
if (
this.minimalMode &&
isSSG &&
cachedData.headers?.['x-next-cache-tags']
) {
res.setHeader(
'x-next-cache-tags',
cachedData.headers['x-next-cache-tags'] as string
)
}
if (isDataReq && typeof cachedData.pageData !== 'string') {
throw new Error(
'invariant: Expected pageData to be a string for app data request but received ' +

View file

@ -21,7 +21,7 @@ import {
handleInternalServerErrorResponse,
} from '../helpers/response-handlers'
import { type HTTP_METHOD, HTTP_METHODS, isHTTPMethod } from '../../../web/http'
import { patchFetch } from '../../../lib/patch-fetch'
import { addImplicitTags, patchFetch } from '../../../lib/patch-fetch'
import { getTracer } from '../../../lib/trace/tracer'
import { AppRouteRouteHandlersSpan } from '../../../lib/trace/constants'
import { getPathnameFromAbsolutePath } from './helpers/get-pathname-from-absolute-path'
@ -349,6 +349,9 @@ export class AppRouteRouteModule extends RouteModule<
await Promise.all(
staticGenerationStore.pendingRevalidates || []
)
addImplicitTags(staticGenerationStore)
;(context.staticGenerationContext as any).fetchTags =
staticGenerationStore.tags?.join(',')
// It's possible cookies were set in the handler, so we need
// to merge the modified cookies and the returned response

View file

@ -1,8 +1,9 @@
import type { OutgoingHttpHeaders } from 'http'
import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from './'
import LRUCache from 'next/dist/compiled/lru-cache'
import { CacheFs } from '../../../shared/lib/utils'
import path from '../../../shared/lib/isomorphic/path'
import { CachedFetchValue } from '../../response-cache'
import { CacheFs } from '../../../shared/lib/utils'
import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from './'
import { CACHE_ONE_YEAR } from '../../../lib/constants'
type FileSystemCacheContext = Omit<
@ -199,12 +200,27 @@ export default class FileSystemCache implements CacheHandler {
)
).toString('utf8')
)
let meta: { status?: number; headers?: OutgoingHttpHeaders } = {}
if (isAppPath) {
try {
meta = JSON.parse(
(
await this.fs.readFile(filePath.replace(/\.html$/, '.meta'))
).toString('utf-8')
)
} catch (_) {}
}
data = {
lastModified: mtime.getTime(),
value: {
kind: 'PAGE',
html: fileData,
pageData,
headers: meta.headers,
status: meta.status,
},
}
}
@ -216,11 +232,65 @@ export default class FileSystemCache implements CacheHandler {
// unable to get data from disk
}
}
let cacheTags: undefined | string[]
if (data?.value?.kind === 'PAGE') {
const tagsHeader = data.value.headers?.['x-next-cache-tags']
if (typeof tagsHeader === 'string') {
cacheTags = tagsHeader.split(',')
}
}
const getDerivedTags = (tags: string[]): string[] => {
const derivedTags: string[] = []
for (const tag of tags || []) {
if (tag.startsWith('/')) {
const pathnameParts = tag.split('/')
// we automatically add the current path segments as tags
// for revalidatePath handling
for (let i = 1; i < pathnameParts.length + 1; i++) {
const curPathname = pathnameParts.slice(0, i).join('/')
if (curPathname) {
derivedTags.push(curPathname)
if (!derivedTags.includes(curPathname)) {
derivedTags.push(curPathname)
}
}
}
} else if (!derivedTags.includes(tag)) {
derivedTags.push(tag)
}
}
return derivedTags
}
if (data?.value?.kind === 'PAGE' && cacheTags?.length) {
this.loadTagsManifest()
const derivedTags = getDerivedTags(cacheTags || [])
const isStale = derivedTags.some((tag) => {
return (
tagsManifest?.items[tag]?.revalidatedAt &&
tagsManifest?.items[tag].revalidatedAt >=
(data?.lastModified || Date.now())
)
})
if (isStale) {
data.lastModified = -1
}
}
if (data && data?.value?.kind === 'FETCH') {
this.loadTagsManifest()
const innerData = data.value.data
const isStale = innerData.tags?.some((tag) => {
const derivedTags = getDerivedTags(innerData.tags || [])
const isStale = derivedTags.some((tag) => {
return (
tagsManifest?.items[tag]?.revalidatedAt &&
tagsManifest?.items[tag].revalidatedAt >=
@ -274,6 +344,16 @@ export default class FileSystemCache implements CacheHandler {
).filePath,
isAppPath ? data.pageData : JSON.stringify(data.pageData)
)
if (data.headers || data.status) {
await this.fs.writeFile(
htmlPath.replace(/\.html$/, '.meta'),
JSON.stringify({
headers: data.headers,
status: data.status,
})
)
}
} else if (data?.kind === 'FETCH') {
const { filePath } = await this.getFsPath({
pathname: key,

View file

@ -10,6 +10,7 @@ import {
} from '../../response-cache'
import { encode } from '../../../shared/lib/bloom-filter/base64-arraybuffer'
import { encodeText } from '../../stream-utils/encode-decode'
import { CACHE_ONE_YEAR } from '../../../lib/constants'
function toRoute(pathname: string): string {
return pathname.replace(/\/$/, '').replace(/\/index$/, '') || '/'
@ -21,9 +22,10 @@ export interface CacheHandlerContext {
flushToDisk?: boolean
serverDistDir?: string
maxMemoryCacheSize?: number
fetchCacheKeyPrefix?: string
prerenderManifest?: PrerenderManifest
_appDir: boolean
_requestHeaders: IncrementalCache['requestHeaders']
fetchCacheKeyPrefix?: string
}
export interface CacheHandlerValue {
@ -327,15 +329,24 @@ export class IncrementalCache {
const curRevalidate =
this.prerenderManifest.routes[toRoute(pathname)]?.initialRevalidateSeconds
const revalidateAfter = this.calculateRevalidate(
pathname,
cacheData?.lastModified || Date.now(),
this.dev && !fetchCache
)
const isStale =
revalidateAfter !== false && revalidateAfter < Date.now()
? true
: undefined
let isStale: boolean | -1 | undefined
let revalidateAfter: false | number
if (cacheData?.lastModified === -1) {
isStale = -1
revalidateAfter = -1 * CACHE_ONE_YEAR
} else {
revalidateAfter = this.calculateRevalidate(
pathname,
cacheData?.lastModified || Date.now(),
this.dev && !fetchCache
)
isStale =
revalidateAfter !== false && revalidateAfter < Date.now()
? true
: undefined
}
if (cacheData) {
entry = {

View file

@ -7,6 +7,19 @@ import { CACHE_ONE_YEAR } from '../../lib/constants'
const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge'
export function addImplicitTags(
staticGenerationStore: ReturnType<StaticGenerationAsyncStorage['getStore']>
) {
if (!staticGenerationStore?.pathname) return
if (!Array.isArray(staticGenerationStore.tags)) {
staticGenerationStore.tags = []
}
if (!staticGenerationStore.tags.includes(staticGenerationStore.pathname)) {
staticGenerationStore.tags.push(staticGenerationStore.pathname)
}
}
// we patch fetch to collect cache information used for
// determining if a page is static or not
export function patchFetch({
@ -81,7 +94,19 @@ export function patchFetch({
// RequestInit doesn't keep extra fields e.g. next so it's
// only available if init is used separate
let curRevalidate = getNextField('revalidate')
const tags: undefined | string[] = getNextField('tags')
const tags: string[] = getNextField('tags') || []
if (Array.isArray(tags)) {
if (!staticGenerationStore.tags) {
staticGenerationStore.tags = []
}
for (const tag of tags) {
if (!staticGenerationStore.tags.includes(tag)) {
staticGenerationStore.tags.push(tag)
}
}
}
addImplicitTags(staticGenerationStore)
const isOnlyCache = staticGenerationStore.fetchCache === 'only-cache'
const isForceCache = staticGenerationStore.fetchCache === 'force-cache'

View file

@ -268,6 +268,7 @@ export type RenderOptsPartial = {
largePageDataBytes?: number
isOnDemandRevalidate?: boolean
strictNextHead: boolean
isMinimalMode?: boolean
}
export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial

View file

@ -38,6 +38,7 @@ export interface RequestMeta {
_nextDataNormalizing?: boolean
_nextMatch?: RouteMatch
_nextIncrementalCache?: any
_nextMinimalMode?: boolean
}
export function getRequestMeta(

View file

@ -111,6 +111,8 @@ export default class ResponseCache {
kind: 'PAGE',
html: RenderResult.fromStatic(cachedResponse.value.html),
pageData: cachedResponse.value.pageData,
headers: cachedResponse.value.headers,
status: cachedResponse.value.status,
}
: cachedResponse.value,
})
@ -121,7 +123,7 @@ export default class ResponseCache {
}
}
const cacheEntry = await responseGenerator(resolved, !!cachedResponse)
const cacheEntry = await responseGenerator(resolved, cachedResponse)
const resolveValue =
cacheEntry === null
? null
@ -150,6 +152,8 @@ export default class ResponseCache {
kind: 'PAGE',
html: cacheEntry.value.html.toUnchunkedString(),
pageData: cacheEntry.value.pageData,
headers: cacheEntry.value.headers,
status: cacheEntry.value.status,
}
: cacheEntry.value,
cacheEntry.revalidate

View file

@ -35,6 +35,8 @@ interface CachedPageValue {
// expects that type instead of a string
html: RenderResult
pageData: Object
status?: number
headers?: OutgoingHttpHeaders
}
export interface CachedRouteValue {
@ -61,13 +63,16 @@ interface IncrementalCachedPageValue {
// the string value
html: string
pageData: Object
headers?: OutgoingHttpHeaders
status?: number
}
export type IncrementalCacheEntry = {
curRevalidate?: number | false
// milliseconds to revalidate after
revalidateAfter: number | false
isStale?: boolean
// -1 here dictates a blocking revalidate should be used
isStale?: boolean | -1
value: IncrementalCacheValue | null
}
@ -87,13 +92,13 @@ export type ResponseCacheValue =
export type ResponseCacheEntry = {
revalidate?: number | false
value: ResponseCacheValue | null
isStale?: boolean
isStale?: boolean | -1
isMiss?: boolean
}
export type ResponseGenerator = (
hasResolved: boolean,
hadCache: boolean
cacheEntry?: IncrementalCacheItem
) => Promise<ResponseCacheEntry | null>
export type IncrementalCacheItem = {
@ -101,7 +106,7 @@ export type IncrementalCacheItem = {
curRevalidate?: number | false
revalidate?: number | false
value: IncrementalCacheValue | null
isStale?: boolean
isStale?: boolean | -1
isMiss?: boolean
} | null

View file

@ -84,7 +84,7 @@ export default class WebResponseCache {
// same promise until we've fully finished our work.
;(async () => {
try {
const cacheEntry = await responseGenerator(resolved, false)
const cacheEntry = await responseGenerator(resolved)
const resolveValue =
cacheEntry === null
? null

View file

@ -3,6 +3,7 @@ import type {
StaticGenerationStore,
} from '../../../client/components/static-generation-async-storage'
import { unstable_revalidateTag } from './unstable-revalidate-tag'
import { headers } from '../../../client/components/headers'
import {
PRERENDER_REVALIDATE_HEADER,
@ -12,9 +13,14 @@ import {
export function unstable_revalidatePath(
path: string,
ctx: {
manualRevalidate?: boolean
unstable_onlyGenerated?: boolean
} = {}
) {
if (!ctx?.manualRevalidate) {
return unstable_revalidateTag(path)
}
const staticGenerationAsyncStorage = (
fetch as any
).__nextGetStaticStore?.() as undefined | StaticGenerationAsyncStorage

View file

@ -32,6 +32,22 @@ createNextDescribe(
}
})
it('should not have cache tags header for non-minimal mode', async () => {
for (const path of [
'/ssr-forced',
'/ssr-forced',
'/variable-revalidate/revalidate-3',
'/variable-revalidate/revalidate-360',
'/variable-revalidate/revalidate-360-isr',
]) {
const res = await fetchViaHTTP(next.url, path, undefined, {
redirect: 'manual',
})
expect(res.status).toBe(200)
expect(res.headers.get('x-next-cache-tags')).toBeFalsy()
}
})
if (isDev) {
it('should error correctly for invalid params from generateStaticParams', async () => {
await next.patchFile(
@ -154,7 +170,7 @@ createNextDescribe(
// On-Demand Revalidate has not effect in dev since app routes
// aren't considered static until prerendering
if (!(global as any).isNextDev) {
if (!(global as any).isNextDev && !process.env.CUSTOM_CACHE_HANDLER) {
it.each([
{
type: 'edge route handler',
@ -205,7 +221,9 @@ createNextDescribe(
// On-Demand Revalidate has not effect in dev
if (!(global as any).isNextDev && !process.env.CUSTOM_CACHE_HANDLER) {
it('should revalidate all fetches during on-demand revalidate', async () => {
const initRes = await next.fetch('/variable-revalidate/revalidate-360')
const initRes = await next.fetch(
'/variable-revalidate/revalidate-360-isr'
)
const html = await initRes.text()
const $ = cheerio.load(html)
const initLayoutData = $('#layout-data').text()
@ -410,6 +428,7 @@ createNextDescribe(
'partial-gen-params/[lang]/[slug]/page.js',
'route-handler-edge/revalidate-360/route.js',
'route-handler/post/route.js',
'route-handler/revalidate-360-isr/route.js',
'route-handler/revalidate-360/route.js',
'ssg-draft-mode.html',
'ssg-draft-mode.rsc',
@ -633,6 +652,16 @@ createNextDescribe(
initialRevalidateSeconds: 3,
srcRoute: '/gen-params-dynamic-revalidate/[slug]',
},
'/route-handler/revalidate-360-isr': {
dataRoute: null,
initialHeaders: {
'content-type': 'application/json',
'x-next-cache-tags':
'thankyounext,/route-handler/revalidate-360-isr',
},
initialRevalidateSeconds: false,
srcRoute: '/route-handler/revalidate-360-isr',
},
'/variable-config-revalidate/revalidate-3': {
dataRoute: '/variable-config-revalidate/revalidate-3.rsc',
initialRevalidateSeconds: 3,

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse, unstable_revalidatePath } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { unstable_revalidatePath } from 'next/cache'
export const runtime = 'edge'

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse, unstable_revalidatePath } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { unstable_revalidatePath } from 'next/cache'
export async function GET(req: NextRequest) {
const path = req.nextUrl.searchParams.get('path') || '/'

View file

@ -1,6 +1,8 @@
import { NextResponse, unstable_revalidateTag } from 'next/server'
import { NextResponse } from 'next/server'
import { unstable_revalidateTag } from 'next/cache'
export const revalidate = 0
export const runtime = 'edge'
export async function GET(req) {
const tag = req.nextUrl.searchParams.get('tag')

View file

@ -1,7 +1,7 @@
import { NextResponse, unstable_revalidateTag } from 'next/server'
import { NextResponse } from 'next/server'
import { unstable_revalidateTag } from 'next/cache'
export const revalidate = 0
export const runtime = 'edge'
export async function GET(req) {
const tag = req.nextUrl.searchParams.get('tag')

View file

@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
export async function GET() {
const data360 = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random',
{
next: {
revalidate: 360,
tags: ['thankyounext'],
},
}
).then((res) => res.text())
const data10 = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random?a=10',
{
next: {
revalidate: 10,
tags: ['thankyounext'],
},
}
).then((res) => res.text())
return NextResponse.json({ data360, data10 })
}

View file

@ -1,4 +1,4 @@
import { unstable_cache } from 'next/server'
import { unstable_cache } from 'next/cache'
export default async function Page() {
const data = await fetch(

View file

@ -1,4 +1,4 @@
import { unstable_cache } from 'next/server'
import { unstable_cache } from 'next/cache'
export const revalidate = 0

View file

@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
export const revalidate = 3
export function generateStaticParams() {
return [{ slug: 'first' }]
}
export async function GET(req, { params }) {
const data = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random',
{
next: {
tags: ['isr-page'],
},
}
).then((res) => res.text())
return NextResponse.json({
now: Date.now(),
params,
data,
})
}

View file

@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
export const revalidate = 0
export function generateStaticParams() {
return [{ slug: 'first' }]
}
export async function GET(req, { params }) {
const data = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random',
{
next: {
tags: ['ssr-page'],
},
}
).then((res) => res.text())
return NextResponse.json({
now: Date.now(),
params,
data,
})
}

View file

@ -0,0 +1,25 @@
export const revalidate = 3
export function generateStaticParams() {
return [{ slug: 'first' }]
}
export default async function Page({ params }) {
const data = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random',
{
next: {
tags: ['isr-page'],
},
}
).then((res) => res.text())
return (
<>
<p id="page">/isr/[slug]</p>
<p id="params">{JSON.stringify(params)}</p>
<p id="now">{Date.now()}</p>
<p id="data">{data}</p>
</>
)
}

View file

@ -0,0 +1,8 @@
export default function Layout({ children }) {
return (
<html lang="en">
<head />
<body>{children}</body>
</html>
)
}

View file

@ -0,0 +1,21 @@
export const revalidate = 0
export default async function Page({ params }) {
const data = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random',
{
next: {
tags: ['ssr-page'],
},
}
).then((res) => res.text())
return (
<>
<p id="page">/ssr/[slug]</p>
<p id="params">{JSON.stringify(params)}</p>
<p id="now">{Date.now()}</p>
<p id="data">{data}</p>
</>
)
}

View file

@ -0,0 +1,130 @@
import glob from 'glob'
import fs from 'fs-extra'
import { join } from 'path'
import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'test/lib/next-modes/base'
import {
fetchViaHTTP,
findPort,
initNextServerScript,
killApp,
} from 'next-test-utils'
describe('should set-up next', () => {
let next: NextInstance
let server
let appPort
const setupNext = async ({
nextEnv,
minimalMode,
}: {
nextEnv?: boolean
minimalMode?: boolean
}) => {
// test build against environment with next support
process.env.NOW_BUILDER = nextEnv ? '1' : ''
next = await createNext({
files: {
app: new FileRef(join(__dirname, 'app')),
lib: new FileRef(join(__dirname, 'lib')),
'middleware.js': new FileRef(join(__dirname, 'middleware.js')),
'data.txt': new FileRef(join(__dirname, 'data.txt')),
'.env': new FileRef(join(__dirname, '.env')),
'.env.local': new FileRef(join(__dirname, '.env.local')),
'.env.production': new FileRef(join(__dirname, '.env.production')),
},
nextConfig: {
eslint: {
ignoreDuringBuilds: true,
},
experimental: {
appDir: true,
},
output: 'standalone',
},
})
await next.stop()
await fs.move(
join(next.testDir, '.next/standalone'),
join(next.testDir, 'standalone')
)
for (const file of await fs.readdir(next.testDir)) {
if (file !== 'standalone') {
await fs.remove(join(next.testDir, file))
console.log('removed', file)
}
}
const files = glob.sync('**/*', {
cwd: join(next.testDir, 'standalone/.next/server/pages'),
dot: true,
})
for (const file of files) {
if (file.endsWith('.json') || file.endsWith('.html')) {
await fs.remove(join(next.testDir, '.next/server', file))
}
}
const testServer = join(next.testDir, 'standalone/server.js')
await fs.writeFile(
testServer,
(await fs.readFile(testServer, 'utf8'))
.replace('console.error(err)', `console.error('top-level', err)`)
.replace('conf:', `minimalMode: ${minimalMode},conf:`)
)
appPort = await findPort()
server = await initNextServerScript(
testServer,
/Listening on/,
{
...process.env,
PORT: appPort,
},
undefined,
{
cwd: next.testDir,
}
)
}
beforeAll(async () => {
await setupNext({ nextEnv: true, minimalMode: true })
})
afterAll(async () => {
await next.destroy()
if (server) await killApp(server)
})
it('should send cache tags in minimal mode for ISR', async () => {
for (const [path, tags] of [
['/isr/first', 'isr-page,/isr/[slug]'],
['/isr/second', 'isr-page,/isr/[slug]'],
['/api/isr/first', 'isr-page,/api/isr/[slug]'],
['/api/isr/second', 'isr-page,/api/isr/[slug]'],
]) {
const res = await fetchViaHTTP(appPort, path, undefined, {
redirect: 'manual',
})
expect(res.status).toBe(200)
expect(res.headers.get('x-next-cache-tags')).toBe(tags)
}
})
it('should not send cache tags in minimal mode for SSR', async () => {
for (const path of [
'/ssr/first',
'/ssr/second',
'/api/ssr/first',
'/api/ssr/second',
]) {
const res = await fetchViaHTTP(appPort, path, undefined, {
redirect: 'manual',
})
expect(res.status).toBe(200)
expect(res.headers.get('x-next-cache-tags')).toBeFalsy()
}
})
})