From eb76546cb58c4c2d9c6371766068d1dc7691b7eb Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 7 Mar 2025 14:05:50 +0100 Subject: [PATCH 1/4] fix: dynamic not-prerendered routes revalidate tracking --- src/run/handlers/cache.cts | 76 ++++++++++++++++++++++++-------------- src/shared/cache-types.cts | 24 +++++++++++- 2 files changed, 72 insertions(+), 28 deletions(-) diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index e40caff46..22b781472 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -183,37 +183,56 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { private async injectEntryToPrerenderManifest( key: string, - revalidate: NetlifyCachedPageValue['revalidate'], + { revalidate, cacheControl }: Pick, ) { - if (this.options.serverDistDir && (typeof revalidate === 'number' || revalidate === false)) { + if ( + this.options.serverDistDir && + (typeof revalidate === 'number' || + revalidate === false || + typeof cacheControl !== 'undefined') + ) { try { const { loadManifest } = await import('next/dist/server/load-manifest.js') const prerenderManifest = loadManifest( join(this.options.serverDistDir, '..', 'prerender-manifest.json'), ) as PrerenderManifest - try { - const { normalizePagePath } = await import( - 'next/dist/shared/lib/page-path/normalize-page-path.js' + if (typeof cacheControl !== undefined) { + // instead of `revalidate` property, we might get `cacheControls` ( https://github.com/vercel/next.js/pull/76207 ) + // then we need to keep track of revalidate values via SharedCacheControls + const { SharedCacheControls } = await import( + // @ts-expect-error supporting multiple next version, this module is not resolvable with currently used dev dependency + // eslint-disable-next-line import/no-unresolved, n/no-missing-import + 'next/dist/server/lib/incremental-cache/shared-cache-controls.js' ) - - prerenderManifest.routes[key] = { - experimentalPPR: undefined, - dataRoute: posixJoin('/_next/data', `${normalizePagePath(key)}.json`), - srcRoute: null, // FIXME: provide actual source route, however, when dynamically appending it doesn't really matter - initialRevalidateSeconds: revalidate, - // Pages routes do not have a prefetch data route. - prefetchDataRoute: undefined, + const sharedCacheControls = new SharedCacheControls(prerenderManifest) + sharedCacheControls.set(key, cacheControl) + } else if (typeof revalidate === 'number' || revalidate === false) { + // if we don't get cacheControls, but we still get revalidate, it should mean we are before + // https://github.com/vercel/next.js/pull/76207 + try { + const { normalizePagePath } = await import( + 'next/dist/shared/lib/page-path/normalize-page-path.js' + ) + + prerenderManifest.routes[key] = { + experimentalPPR: undefined, + dataRoute: posixJoin('/_next/data', `${normalizePagePath(key)}.json`), + srcRoute: null, // FIXME: provide actual source route, however, when dynamically appending it doesn't really matter + initialRevalidateSeconds: revalidate, + // Pages routes do not have a prefetch data route. + prefetchDataRoute: undefined, + } + } catch { + // depending on Next.js version - prerender manifest might not be mutable + // https://github.com/vercel/next.js/pull/64313 + // if it's not mutable we will try to use SharedRevalidateTimings ( https://github.com/vercel/next.js/pull/64370) instead + const { SharedRevalidateTimings } = await import( + 'next/dist/server/lib/incremental-cache/shared-revalidate-timings.js' + ) + const sharedRevalidateTimings = new SharedRevalidateTimings(prerenderManifest) + sharedRevalidateTimings.set(key, revalidate) } - } catch { - // depending on Next.js version - prerender manifest might not be mutable - // https://github.com/vercel/next.js/pull/64313 - // if it's not mutable we will try to use SharedRevalidateTimings ( https://github.com/vercel/next.js/pull/64370) instead - const { SharedRevalidateTimings } = await import( - 'next/dist/server/lib/incremental-cache/shared-revalidate-timings.js' - ) - const sharedRevalidateTimings = new SharedRevalidateTimings(prerenderManifest) - sharedRevalidateTimings.set(key, revalidate) } } catch {} } @@ -315,7 +334,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { span.addEvent(blob.value?.kind, { lastModified: blob.lastModified, revalidate, ttl }) - await this.injectEntryToPrerenderManifest(key, revalidate) + await this.injectEntryToPrerenderManifest(key, blob.value) return { lastModified: blob.lastModified, @@ -327,7 +346,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { span.addEvent(blob.value?.kind, { lastModified: blob.lastModified, revalidate, ttl }) - await this.injectEntryToPrerenderManifest(key, revalidate) + await this.injectEntryToPrerenderManifest(key, blob.value) return { lastModified: blob.lastModified, @@ -355,7 +374,8 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { if (isCachedRouteValue(data)) { return { ...data, - revalidate: context.revalidate, + revalidate: context.revalidate ?? context.cacheControl?.revalidate, + cacheControl: context.cacheControl, body: data.body.toString('base64'), } } @@ -363,14 +383,16 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { if (isCachedPageValue(data)) { return { ...data, - revalidate: context.revalidate, + revalidate: context.revalidate ?? context.cacheControl?.revalidate, + cacheControl: context.cacheControl, } } if (data?.kind === 'APP_PAGE') { return { ...data, - revalidate: context.revalidate, + revalidate: context.revalidate ?? context.cacheControl?.revalidate, + cacheControl: context.cacheControl, rscData: data.rscData?.toString('base64'), } } diff --git a/src/shared/cache-types.cts b/src/shared/cache-types.cts index 9cbfd9b13..defba8e6f 100644 --- a/src/shared/cache-types.cts +++ b/src/shared/cache-types.cts @@ -12,6 +12,11 @@ import type { export type { CacheHandlerContext } from 'next/dist/server/lib/incremental-cache/index.js' +type CacheControl = { + revalidate: Parameters[2]['revalidate'] + expire: number | undefined +} + /** * Shape of the cache value that is returned from CacheHandler.get or passed to CacheHandler.set */ @@ -28,6 +33,7 @@ export type NetlifyCachedRouteValue = Omit[2]['revalidate'] + cacheControl?: CacheControl } /** @@ -50,6 +56,7 @@ export type NetlifyCachedAppPageValue = Omit< // Next.js stores rscData as buffer, while we store it as base64 encoded string rscData: string | undefined revalidate?: Parameters[2]['revalidate'] + cacheControl?: CacheControl } /** @@ -64,6 +71,7 @@ type IncrementalCachedPageValueForMultipleVersions = Omit[2]['revalidate'] + cacheControl?: CacheControl } export type CachedFetchValueForMultipleVersions = Omit & { @@ -131,4 +139,18 @@ type MapCacheHandlerClassMethod = T extends (...args: infer Args) => infer Re type MapCacheHandlerClass = { [K in keyof T]: MapCacheHandlerClassMethod } -export type CacheHandlerForMultipleVersions = MapCacheHandlerClass +type BaseCacheHandlerForMultipleVersions = MapCacheHandlerClass + +type CacheHandlerSetContext = Parameters[2] + +type CacheHandlerSetContextForMultipleVersions = CacheHandlerSetContext & { + cacheControl?: CacheControl +} + +export type CacheHandlerForMultipleVersions = BaseCacheHandlerForMultipleVersions & { + set: ( + key: Parameters[0], + value: Parameters[1], + context: CacheHandlerSetContextForMultipleVersions, + ) => ReturnType +} From 27a82bdfb1158132d1faef92ae6338088ffcc51d Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 7 Mar 2025 14:47:17 +0100 Subject: [PATCH 2/4] fix: correct typeof check --- src/run/handlers/cache.cts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index 22b781472..fc40ec4bd 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -197,7 +197,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { join(this.options.serverDistDir, '..', 'prerender-manifest.json'), ) as PrerenderManifest - if (typeof cacheControl !== undefined) { + if (typeof cacheControl !== 'undefined') { // instead of `revalidate` property, we might get `cacheControls` ( https://github.com/vercel/next.js/pull/76207 ) // then we need to keep track of revalidate values via SharedCacheControls const { SharedCacheControls } = await import( From ccf27887d6385b9371c3a9c6368d7f2dec2fb341 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 7 Mar 2025 15:01:26 +0100 Subject: [PATCH 3/4] test: adjust cache-status assertions for stale responses serverd by durable --- tests/e2e/page-router.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/page-router.test.ts b/tests/e2e/page-router.test.ts index bcd5645de..a2471c980 100644 --- a/tests/e2e/page-router.test.ts +++ b/tests/e2e/page-router.test.ts @@ -413,12 +413,12 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => { expect(date1.localeCompare(beforeFirstFetch)).toBeGreaterThan(0) // allow page to get stale - await page.waitForTimeout(60_000) + await page.waitForTimeout(61_000) const response2 = await page.goto(new URL(pathname, pageRouter.url).href) expect(response2?.status()).toBe(200) expect(response2?.headers()['cache-status']).toMatch( - /"Netlify (Edge|Durable)"; hit; fwd=stale/m, + /("Netlify Edge"; hit; fwd=stale|"Netlify Durable"; hit; ttl=-[0-9]+)/m, ) expect(response2?.headers()['netlify-cdn-cache-control']).toMatch( /s-maxage=60, stale-while-revalidate=[0-9]+, durable/, @@ -436,8 +436,8 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => { const response3 = await page.goto(new URL(pathname, pageRouter.url).href) expect(response3?.status()).toBe(200) expect(response3?.headers()['cache-status']).toMatch( - // hit, without being followed by ';fwd=stale' - /"Netlify (Edge|Durable)"; hit(?!; fwd=stale)/m, + // hit, without being followed by ';fwd=stale' for edge or negative TTL for durable, optionally with fwd=stale + /("Netlify Edge"; hit(?!; fwd=stale)|"Netlify Durable"; hit(?!; ttl=-[0-9]+))/m, ) expect(response3?.headers()['netlify-cdn-cache-control']).toMatch( /s-maxage=60, stale-while-revalidate=[0-9]+, durable/, From a353ed12b63e6a964b0ccb6a47ac0c42199f38fb Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 7 Mar 2025 15:41:40 +0100 Subject: [PATCH 4/4] test: ensure we don't prerender API responses for og image in test fixture --- tests/fixtures/wasm-src/src/app/og-node/route.js | 2 ++ tests/fixtures/wasm-src/src/app/og/route.js | 2 ++ tests/fixtures/wasm/app/og-node/route.js | 2 ++ tests/fixtures/wasm/app/og/route.js | 2 ++ 4 files changed, 8 insertions(+) diff --git a/tests/fixtures/wasm-src/src/app/og-node/route.js b/tests/fixtures/wasm-src/src/app/og-node/route.js index 39638abb9..6338e7e61 100644 --- a/tests/fixtures/wasm-src/src/app/og-node/route.js +++ b/tests/fixtures/wasm-src/src/app/og-node/route.js @@ -6,3 +6,5 @@ export async function GET() { height: 630, }) } + +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/wasm-src/src/app/og/route.js b/tests/fixtures/wasm-src/src/app/og/route.js index 9304ca61e..575c5a01a 100644 --- a/tests/fixtures/wasm-src/src/app/og/route.js +++ b/tests/fixtures/wasm-src/src/app/og/route.js @@ -8,3 +8,5 @@ export async function GET() { } export const runtime = 'edge' + +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/wasm/app/og-node/route.js b/tests/fixtures/wasm/app/og-node/route.js index 39638abb9..6338e7e61 100644 --- a/tests/fixtures/wasm/app/og-node/route.js +++ b/tests/fixtures/wasm/app/og-node/route.js @@ -6,3 +6,5 @@ export async function GET() { height: 630, }) } + +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/wasm/app/og/route.js b/tests/fixtures/wasm/app/og/route.js index 9304ca61e..575c5a01a 100644 --- a/tests/fixtures/wasm/app/og/route.js +++ b/tests/fixtures/wasm/app/og/route.js @@ -8,3 +8,5 @@ export async function GET() { } export const runtime = 'edge' + +export const dynamic = 'force-dynamic'