Skip to content

Commit 2a41703

Browse files
piehserhalp
andauthored
fix: mark prerendered content as less stale now that expire setting is enforced for marking for blocking re-render (#3510)
Co-authored-by: Philippe Serhal <philippe.serhal@netlify.com>
1 parent d4f3a88 commit 2a41703

2 files changed

Lines changed: 113 additions & 21 deletions

File tree

src/build/content/prerendered.ts

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { join } from 'node:path'
55
import { trace } from '@opentelemetry/api'
66
import { wrapTracer } from '@opentelemetry/api/experimental'
77
import { glob } from 'fast-glob'
8+
import type { PrerenderManifestRoute } from 'next-with-cache-handler-v2/dist/build/index.js'
89
import type { RouteMetadata } from 'next-with-cache-handler-v2/dist/export/routes/types.js'
910
import pLimit from 'p-limit'
1011
import { satisfies } from 'semver'
@@ -56,9 +57,34 @@ const routeToFilePath = (path: string) => {
5657
return `/${path}`
5758
}
5859

60+
function prerenderManifestRouteToRevalidateAndCacheControlProperties(
61+
prerenderManifestRoute: PrerenderManifestRoute | undefined,
62+
) {
63+
if (!prerenderManifestRoute) {
64+
return {}
65+
}
66+
67+
return {
68+
revalidate: prerenderManifestRoute.initialRevalidateSeconds,
69+
cacheControl: prerenderManifestRoute.initialRevalidateSeconds
70+
? {
71+
revalidate: prerenderManifestRoute.initialRevalidateSeconds,
72+
expire:
73+
typeof prerenderManifestRoute.initialExpireSeconds === 'number'
74+
? prerenderManifestRoute.initialExpireSeconds +
75+
(prerenderManifestRoute.initialExpireSeconds ===
76+
prerenderManifestRoute.initialRevalidateSeconds
77+
? 31536000000
78+
: 0)
79+
: undefined,
80+
}
81+
: undefined,
82+
}
83+
}
84+
5985
const buildPagesCacheValue = async (
6086
path: string,
61-
initialRevalidateSeconds: number | false | undefined,
87+
prerenderManifestRoute: PrerenderManifestRoute | undefined,
6288
shouldUseEnumKind: boolean,
6389
shouldSkipJson = false,
6490
): Promise<NetlifyCachedPageValue> => ({
@@ -67,14 +93,15 @@ const buildPagesCacheValue = async (
6793
pageData: shouldSkipJson ? {} : JSON.parse(await readFile(`${path}.json`, 'utf-8')),
6894
headers: undefined,
6995
status: undefined,
70-
revalidate: initialRevalidateSeconds,
96+
...prerenderManifestRouteToRevalidateAndCacheControlProperties(prerenderManifestRoute),
7197
})
7298

7399
const RSC_SEGMENTS_DIR_SUFFIX = '.segments'
74100
const RSC_SEGMENT_SUFFIX = '.segment.rsc'
75101

76102
const buildAppCacheValue = async (
77103
path: string,
104+
prerenderManifestRoute: PrerenderManifestRoute | undefined,
78105
shouldUseAppPageKind: boolean,
79106
rscIsRequired = true,
80107
): Promise<NetlifyCachedAppPageValue | NetlifyCachedPageValue> => {
@@ -117,6 +144,7 @@ const buildAppCacheValue = async (
117144
}),
118145
segmentData,
119146
...meta,
147+
...prerenderManifestRouteToRevalidateAndCacheControlProperties(prerenderManifestRoute),
120148
}
121149
}
122150

@@ -141,26 +169,37 @@ const buildAppCacheValue = async (
141169
html,
142170
pageData: rsc,
143171
...meta,
172+
...prerenderManifestRouteToRevalidateAndCacheControlProperties(prerenderManifestRoute),
144173
}
145174
}
146175

147176
const buildRouteCacheValue = async (
148177
path: string,
149-
initialRevalidateSeconds: number | false,
178+
prerenderManifestRoute: PrerenderManifestRoute,
150179
shouldUseEnumKind: boolean,
151180
): Promise<NetlifyCachedRouteValue> => ({
152181
kind: shouldUseEnumKind ? 'APP_ROUTE' : 'ROUTE',
153182
body: await readFile(`${path}.body`, 'base64'),
154183
...JSON.parse(await readFile(`${path}.meta`, 'utf-8')),
155-
revalidate: initialRevalidateSeconds,
184+
...prerenderManifestRouteToRevalidateAndCacheControlProperties(prerenderManifestRoute),
156185
})
157186

158187
const buildFetchCacheValue = async (
159188
path: string,
160-
): Promise<CachedFetchValueForMultipleVersions> => ({
161-
kind: 'FETCH',
162-
...JSON.parse(await readFile(path, 'utf-8')),
163-
})
189+
): Promise<{ value: CachedFetchValueForMultipleVersions; lastModified: number }> => {
190+
const data = JSON.parse(await readFile(path, 'utf-8')) as Omit<
191+
CachedFetchValueForMultipleVersions,
192+
'kind'
193+
>
194+
195+
return {
196+
value: {
197+
kind: 'FETCH',
198+
...data,
199+
},
200+
lastModified: Date.now() - (data?.revalidate ?? 31536000000),
201+
}
202+
}
164203

165204
/**
166205
* Upload prerendered content to the blob store
@@ -198,43 +237,44 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise<void>
198237

199238
await Promise.all([
200239
...Object.entries(manifest.routes).map(
201-
([route, meta]): Promise<void> =>
240+
([route, prerenderManifestRoute]): Promise<void> =>
202241
limitConcurrentPrerenderContentHandling(async () => {
203-
const lastModified = meta.initialRevalidateSeconds
204-
? Date.now() - 31536000000
242+
const lastModified = prerenderManifestRoute.initialRevalidateSeconds
243+
? Date.now() - prerenderManifestRoute.initialRevalidateSeconds * 1000
205244
: Date.now()
206245
const key = routeToFilePath(route)
207246
let value: NetlifyIncrementalCacheValue
208247
switch (true) {
209248
// Parallel route default layout has no prerendered page
210-
case meta.dataRoute?.endsWith('/default.rsc') &&
249+
case prerenderManifestRoute.dataRoute?.endsWith('/default.rsc') &&
211250
!existsSync(join(ctx.publishDir, 'server/app', `${key}.html`)):
212251
return
213-
case meta.dataRoute?.endsWith('.json'):
252+
case prerenderManifestRoute.dataRoute?.endsWith('.json'):
214253
if (manifest.notFoundRoutes.includes(route)) {
215254
// if pages router returns 'notFound: true', build won't produce html and json files
216255
return
217256
}
218257
value = await buildPagesCacheValue(
219258
join(ctx.publishDir, 'server/pages', key),
220-
meta.initialRevalidateSeconds,
259+
prerenderManifestRoute,
221260
shouldUseEnumKind,
222261
)
223262
break
224-
case meta.dataRoute?.endsWith('.rsc'):
263+
case prerenderManifestRoute.dataRoute?.endsWith('.rsc'):
225264
value = await buildAppCacheValue(
226265
join(ctx.publishDir, 'server/app', key),
266+
prerenderManifestRoute,
227267
shouldUseAppPageKind,
228-
meta.renderingMode !== 'PARTIALLY_STATIC',
268+
prerenderManifestRoute.renderingMode !== 'PARTIALLY_STATIC',
229269
)
230270
if (route === '/_not-found') {
231271
appRouterNotFoundDefinedInPrerenderManifest = true
232272
}
233273
break
234-
case meta.dataRoute === null:
274+
case prerenderManifestRoute.dataRoute === null:
235275
value = await buildRouteCacheValue(
236276
join(ctx.publishDir, 'server/app', key),
237-
meta.initialRevalidateSeconds,
277+
prerenderManifestRoute,
238278
shouldUseEnumKind,
239279
)
240280
break
@@ -268,6 +308,7 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise<void>
268308
const key = routeToFilePath(route)
269309
const value = await buildAppCacheValue(
270310
join(ctx.publishDir, 'server/app', key),
311+
undefined,
271312
shouldUseAppPageKind,
272313
// shells always have `renderingMode === 'PARTIALLY_STATIC'`
273314
false,
@@ -288,6 +329,7 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise<void>
288329
const key = '/404'
289330
const value = await buildAppCacheValue(
290331
join(ctx.publishDir, 'server/app/_not-found'),
332+
undefined,
291333
shouldUseAppPageKind,
292334
)
293335
await writeCacheEntry(key, value, lastModified, ctx)
@@ -310,9 +352,8 @@ export const copyFetchContent = async (ctx: PluginContext): Promise<void> => {
310352

311353
await Promise.all(
312354
paths.map(async (key): Promise<void> => {
313-
const lastModified = Date.now() - 31536000000
314355
const path = join(ctx.publishDir, 'cache/fetch-cache', key)
315-
const value = await buildFetchCacheValue(path)
356+
const { value, lastModified } = await buildFetchCacheValue(path)
316357
await writeCacheEntry(key, value, lastModified, ctx)
317358
}),
318359
)

src/run/handlers/cache.cts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
315315
span?.addEvent('Stale', { staleByTags, key, ttl })
316316
// note that we modify this after we capture last modified to ensure that Age is correct
317317
// but we still let Next.js know that entry is stale
318-
blob.lastModified = -1 // indicate that the entry is stale
318+
this.markCacheEntryStaleByTags(blob)
319319
}
320320

321321
// Next sets a kind/kindHint and fetchUrl for data requests, however fetchUrl was found to be most reliable across versions
@@ -514,6 +514,57 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
514514
// see getRequestSpecificInMemoryCache
515515
}
516516

517+
/**
518+
* Mutates a cache entry that was found to be stale (but not yet expired) through
519+
* on-demand revalidated tags so that Next.js serves it stale while triggering a
520+
* background revalidation.
521+
*
522+
* We can NOT signal staleness with `lastModified = -1` for full-route cache
523+
* entries anymore: since Next.js 16 that sentinel means "entry is past its
524+
* `expire` → do a blocking re-render" rather than "serve stale". See the
525+
* `incremental-cache` `get`: `lastModified === -1` ⇒ `isStale = -1`, and the
526+
* response-cache treats `isStale === -1` as "skip the early stale resolve and
527+
* block on a fresh render".
528+
*
529+
* Instead we drive Next.js' native staleness math, which both old and new
530+
* Next.js resolve to `isStale === true` (serve stale + background revalidation):
531+
* - `revalidate: 1` + a `lastModified` 2s in the past ⇒ `revalidateAfter = now - 1000 < now` ⇒ stale
532+
* - `expire: undefined` ⇒ `expireAfter` undefined ⇒ never the `-1` block path
533+
*
534+
* `revalidate` must be `>= 1` (Next.js 16 rejects `revalidate: 0` with "Invalid
535+
* revalidate configuration provided: 0 < 1") and is needed because force-static
536+
* entries have `revalidate: false`, which would otherwise resolve as fresh.
537+
*
538+
* Actual expiry is still enforced by `checkCacheEntryStaleByTags`: once the tag's
539+
* `expireAt` is reached it reports the entry as expired and `get` returns `null`
540+
* (cache miss → blocking re-render), so we don't need to encode `expire` here.
541+
*/
542+
private markCacheEntryStaleByTags(blob: NetlifyCacheHandlerValue) {
543+
if (!blob.value) {
544+
return
545+
}
546+
547+
if (
548+
blob.value.kind === 'ROUTE' ||
549+
blob.value.kind === 'APP_ROUTE' ||
550+
blob.value.kind === 'PAGE' ||
551+
blob.value.kind === 'PAGES' ||
552+
blob.value.kind === 'APP_PAGE' ||
553+
blob.value.kind === 'REDIRECT'
554+
) {
555+
blob.lastModified = Date.now() - 2 * 1000
556+
blob.value.cacheControl = { revalidate: 1, expire: undefined }
557+
blob.value.revalidate = 1
558+
return
559+
}
560+
561+
// FETCH (and any other) entries are classified as stale by comparing their age
562+
// against the entry's `revalidate` and do not use the `lastModified === -1`
563+
// sentinel as a "blocking re-render" signal, so we keep using it to force
564+
// staleness regardless of the entry's `revalidate`.
565+
blob.lastModified = -1
566+
}
567+
517568
/**
518569
* Checks if a cache entry is stale through on demand revalidated tags
519570
*/

0 commit comments

Comments
 (0)