From 6bc9f79e48cfa47faf7aa83205c85ec455ac1a38 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 11 Apr 2024 17:55:30 -0600 Subject: [PATCH] Shared Revalidate Timings (#64370) ### What? This creates a new `SharedRevalidateTimings` type that is safe to share amongst different points within the framework for sharing revalidation timings. This is a precursor to #64313 which freezes loaded manifests. ### Why? Using the `SharedRevalidateTimings` type, we no-longer have to modify the in-memory instance of the prerender manifest to share the revalidation timings for different routes. Closes NEXT-3083 --- packages/next/src/server/base-server.ts | 4 +- .../src/server/lib/incremental-cache/index.ts | 79 +++++++++---------- .../shared-revalidate-timings.test.ts | 61 ++++++++++++++ .../shared-revalidate-timings.ts | 63 +++++++++++++++ packages/next/src/server/lib/to-route.test.ts | 33 ++++++++ packages/next/src/server/lib/to-route.ts | 26 ++++++ .../next/src/server/response-cache/types.ts | 4 +- 7 files changed, 223 insertions(+), 47 deletions(-) create mode 100644 packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.test.ts create mode 100644 packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.ts create mode 100644 packages/next/src/server/lib/to-route.test.ts create mode 100644 packages/next/src/server/lib/to-route.ts diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 3a95c154ff6ae..a53fc5fcd1306 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -134,6 +134,7 @@ import { PrefetchRSCPathnameNormalizer } from './future/normalizers/request/pref import { NextDataPathnameNormalizer } from './future/normalizers/request/next-data' import { getIsServerAction } from './lib/server-action-request-meta' import { isInterceptionRouteAppPath } from './future/helpers/interception-routes' +import { toRoute } from './lib/to-route' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -1823,8 +1824,7 @@ export default abstract class Server { ) { isSSG = true } else if (!this.renderOpts.dev) { - isSSG ||= - !!prerenderManifest.routes[pathname === '/index' ? '/' : pathname] + isSSG ||= !!prerenderManifest.routes[toRoute(pathname)] } // Toggle whether or not this is a Data request diff --git a/packages/next/src/server/lib/incremental-cache/index.ts b/packages/next/src/server/lib/incremental-cache/index.ts index 58d47c8324197..83df85c456bcd 100644 --- a/packages/next/src/server/lib/incremental-cache/index.ts +++ b/packages/next/src/server/lib/incremental-cache/index.ts @@ -6,10 +6,10 @@ import type { IncrementalCache as IncrementalCacheType, IncrementalCacheKindHint, } from '../../response-cache' +import type { Revalidate } from '../revalidate' import FetchCache from './fetch-cache' import FileSystemCache from './file-system-cache' -import path from '../../../shared/lib/isomorphic/path' import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path' import { @@ -18,10 +18,8 @@ import { NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER, PRERENDER_REVALIDATE_HEADER, } from '../../../lib/constants' - -function toRoute(pathname: string): string { - return pathname.replace(/\/$/, '').replace(/\/index$/, '') || '/' -} +import { toRoute } from '../to-route' +import { SharedRevalidateTimings } from './shared-revalidate-timings' export interface CacheHandlerContext { fs?: CacheFs @@ -65,20 +63,27 @@ export class CacheHandler { } export class IncrementalCache implements IncrementalCacheType { - dev?: boolean - disableForTestmode?: boolean - cacheHandler?: CacheHandler - hasCustomCacheHandler: boolean - prerenderManifest: PrerenderManifest - requestHeaders: Record - requestProtocol?: 'http' | 'https' - allowedRevalidateHeaderKeys?: string[] - minimalMode?: boolean - fetchCacheKeyPrefix?: string - revalidatedTags?: string[] - isOnDemandRevalidate?: boolean - private locks = new Map>() - private unlocks = new Map Promise>() + readonly dev?: boolean + readonly disableForTestmode?: boolean + readonly cacheHandler?: CacheHandler + readonly hasCustomCacheHandler: boolean + readonly prerenderManifest: PrerenderManifest + readonly requestHeaders: Record + readonly requestProtocol?: 'http' | 'https' + readonly allowedRevalidateHeaderKeys?: string[] + readonly minimalMode?: boolean + readonly fetchCacheKeyPrefix?: string + readonly revalidatedTags?: string[] + readonly isOnDemandRevalidate?: boolean + + private readonly locks = new Map>() + private readonly unlocks = new Map Promise>() + + /** + * The revalidate timings for routes. This will source the timings from the + * prerender manifest until the in-memory cache is updated with new timings. + */ + private readonly revalidateTimings: SharedRevalidateTimings constructor({ fs, @@ -152,6 +157,7 @@ export class IncrementalCache implements IncrementalCacheType { this.requestProtocol = requestProtocol this.allowedRevalidateHeaderKeys = allowedRevalidateHeaderKeys this.prerenderManifest = getPrerenderManifest() + this.revalidateTimings = new SharedRevalidateTimings(this.prerenderManifest) this.fetchCacheKeyPrefix = fetchCacheKeyPrefix let revalidatedTags: string[] = [] @@ -193,18 +199,16 @@ export class IncrementalCache implements IncrementalCacheType { pathname: string, fromTime: number, dev?: boolean - ): number | false { + ): Revalidate { // in development we don't have a prerender-manifest // and default to always revalidating to allow easier debugging if (dev) return new Date().getTime() - 1000 // if an entry isn't present in routes we fallback to a default - // of revalidating after 1 second - const { initialRevalidateSeconds } = this.prerenderManifest.routes[ - toRoute(pathname) - ] || { - initialRevalidateSeconds: 1, - } + // of revalidating after 1 second. + const initialRevalidateSeconds = + this.revalidateTimings.get(toRoute(pathname)) ?? 1 + const revalidateAfter = typeof initialRevalidateSeconds === 'number' ? initialRevalidateSeconds * 1000 + fromTime @@ -485,11 +489,10 @@ export class IncrementalCache implements IncrementalCacheType { } } - const curRevalidate = - this.prerenderManifest.routes[toRoute(cacheKey)]?.initialRevalidateSeconds + const curRevalidate = this.revalidateTimings.get(toRoute(cacheKey)) let isStale: boolean | -1 | undefined - let revalidateAfter: false | number + let revalidateAfter: Revalidate if (cacheData?.lastModified === -1) { isStale = -1 @@ -584,22 +587,12 @@ export class IncrementalCache implements IncrementalCacheType { pathname = this._getPathname(pathname, ctx.fetchCache) try { - // we use the prerender manifest memory instance - // to store revalidate timings for calculating - // revalidateAfter values so we update this on set + // Set the value for the revalidate seconds so if it changes we can + // update the cache with the new value. if (typeof ctx.revalidate !== 'undefined' && !ctx.fetchCache) { - this.prerenderManifest.routes[pathname] = { - experimentalPPR: undefined, - dataRoute: path.posix.join( - '/_next/data', - `${normalizePagePath(pathname)}.json` - ), - srcRoute: null, // FIXME: provide actual source route, however, when dynamically appending it doesn't really matter - initialRevalidateSeconds: ctx.revalidate, - // Pages routes do not have a prefetch data route. - prefetchDataRoute: undefined, - } + this.revalidateTimings.set(pathname, ctx.revalidate) } + await this.cacheHandler?.set(pathname, data, ctx) } catch (error) { console.warn('Failed to update prerender cache for', pathname, error) diff --git a/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.test.ts b/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.test.ts new file mode 100644 index 0000000000000..8b08fedc24138 --- /dev/null +++ b/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.test.ts @@ -0,0 +1,61 @@ +import { SharedRevalidateTimings } from './shared-revalidate-timings' + +describe('SharedRevalidateTimings', () => { + let sharedRevalidateTimings: SharedRevalidateTimings + let prerenderManifest + + beforeEach(() => { + prerenderManifest = { + routes: { + '/route1': { + initialRevalidateSeconds: 10, + dataRoute: null, + srcRoute: null, + prefetchDataRoute: null, + experimentalPPR: undefined, + }, + '/route2': { + initialRevalidateSeconds: 20, + dataRoute: null, + srcRoute: null, + prefetchDataRoute: null, + experimentalPPR: undefined, + }, + }, + } + sharedRevalidateTimings = new SharedRevalidateTimings(prerenderManifest) + }) + + afterEach(() => { + sharedRevalidateTimings.clear() + }) + + it('should get revalidate timing from in-memory cache', () => { + sharedRevalidateTimings.set('/route1', 15) + const revalidate = sharedRevalidateTimings.get('/route1') + expect(revalidate).toBe(15) + }) + + it('should get revalidate timing from prerender manifest if not in cache', () => { + const revalidate = sharedRevalidateTimings.get('/route2') + expect(revalidate).toBe(20) + }) + + it('should return undefined if revalidate timing not found', () => { + const revalidate = sharedRevalidateTimings.get('/route3') + expect(revalidate).toBeUndefined() + }) + + it('should set revalidate timing in cache', () => { + sharedRevalidateTimings.set('/route3', 30) + const revalidate = sharedRevalidateTimings.get('/route3') + expect(revalidate).toBe(30) + }) + + it('should clear the in-memory cache', () => { + sharedRevalidateTimings.set('/route3', 30) + sharedRevalidateTimings.clear() + const revalidate = sharedRevalidateTimings.get('/route3') + expect(revalidate).toBeUndefined() + }) +}) diff --git a/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.ts b/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.ts new file mode 100644 index 0000000000000..849b12c0dfaa4 --- /dev/null +++ b/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.ts @@ -0,0 +1,63 @@ +import type { PrerenderManifest } from '../../../build' +import type { Revalidate } from '../revalidate' + +/** + * A shared cache of revalidate timings for routes. This cache is used so we + * don't have to modify the prerender manifest when we want to update the + * revalidate timings for a route. + */ +export class SharedRevalidateTimings { + /** + * The in-memory cache of revalidate timings for routes. This cache is + * populated when the cache is updated with new timings. + */ + private static readonly timings = new Map() + + constructor( + /** + * The prerender manifest that contains the initial revalidate timings for + * routes. + */ + private readonly prerenderManifest: Pick + ) {} + + /** + * Try to get the revalidate timings for a route. This will first try to get + * the timings from the in-memory cache. If the timings are not present in the + * in-memory cache, then the timings will be sourced from the prerender + * manifest. + * + * @param route the route to get the revalidate timings for + * @returns the revalidate timings for the route, or undefined if the timings + * are not present in the in-memory cache or the prerender manifest + */ + public get(route: string): Revalidate | undefined { + // This is a copy on write cache that is updated when the cache is updated. + // If the cache is never written to, then the timings will be sourced from + // the prerender manifest. + let revalidate = SharedRevalidateTimings.timings.get(route) + if (typeof revalidate !== 'undefined') return revalidate + + revalidate = this.prerenderManifest.routes[route]?.initialRevalidateSeconds + if (typeof revalidate !== 'undefined') return revalidate + + return undefined + } + + /** + * Set the revalidate timings for a route. + * + * @param route the route to set the revalidate timings for + * @param revalidate the revalidate timings for the route + */ + public set(route: string, revalidate: Revalidate) { + SharedRevalidateTimings.timings.set(route, revalidate) + } + + /** + * Clear the in-memory cache of revalidate timings for routes. + */ + public clear() { + SharedRevalidateTimings.timings.clear() + } +} diff --git a/packages/next/src/server/lib/to-route.test.ts b/packages/next/src/server/lib/to-route.test.ts new file mode 100644 index 0000000000000..a5beb8bd88392 --- /dev/null +++ b/packages/next/src/server/lib/to-route.test.ts @@ -0,0 +1,33 @@ +import { toRoute } from './to-route' + +describe('toRoute Function', () => { + it('should remove trailing slash', () => { + const result = toRoute('/example/') + expect(result).toBe('/example') + }) + + it('should remove trailing `/index`', () => { + const result = toRoute('/example/index') + expect(result).toBe('/example') + }) + + it('should return `/` when input is `/index`', () => { + const result = toRoute('/index') + expect(result).toBe('/') + }) + + it('should return `/` when input is `/index/`', () => { + const result = toRoute('/index/') + expect(result).toBe('/') + }) + + it('should return `/` when input is only a slash', () => { + const result = toRoute('/') + expect(result).toBe('/') + }) + + it('should return `/` when input is empty', () => { + const result = toRoute('') + expect(result).toBe('/') + }) +}) diff --git a/packages/next/src/server/lib/to-route.ts b/packages/next/src/server/lib/to-route.ts new file mode 100644 index 0000000000000..8685052e37f81 --- /dev/null +++ b/packages/next/src/server/lib/to-route.ts @@ -0,0 +1,26 @@ +/** + * This transforms a URL pathname into a route. It removes any trailing slashes + * and the `/index` suffix. + * + * @param {string} pathname - The URL path that needs to be optimized. + * @returns {string} - The route + * + * @example + * // returns '/example' + * toRoute('/example/index/'); + * + * @example + * // returns '/example' + * toRoute('/example/'); + * + * @example + * // returns '/' + * toRoute('/index/'); + * + * @example + * // returns '/' + * toRoute('/'); + */ +export function toRoute(pathname: string): string { + return pathname.replace(/(?:\/index)?\/?$/, '') || '/' +} diff --git a/packages/next/src/server/response-cache/types.ts b/packages/next/src/server/response-cache/types.ts index 87fa6e9285413..4f098edf9932c 100644 --- a/packages/next/src/server/response-cache/types.ts +++ b/packages/next/src/server/response-cache/types.ts @@ -81,9 +81,9 @@ interface IncrementalCachedPageValue { } export type IncrementalCacheEntry = { - curRevalidate?: number | false + curRevalidate?: Revalidate // milliseconds to revalidate after - revalidateAfter: number | false + revalidateAfter: Revalidate // -1 here dictates a blocking revalidate should be used isStale?: boolean | -1 value: IncrementalCacheValue | null