From 845d6b0cbca57da8ca5e522c36534331ee3e2b69 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Mon, 7 Apr 2025 14:02:35 +0200 Subject: [PATCH 1/9] basic implementation for composable cache --- .../src/adapters/composable-cache.ts | 55 +++++++++++++++++++ packages/open-next/src/build/compileCache.ts | 40 +++++++++++++- .../open-next/src/build/createServerBundle.ts | 13 +++++ packages/open-next/src/core/util.ts | 7 +++ .../overrides/tagCache/dynamodb-nextMode.ts | 4 ++ packages/open-next/src/types/cache.ts | 2 + packages/open-next/src/types/overrides.ts | 2 + 7 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 packages/open-next/src/adapters/composable-cache.ts diff --git a/packages/open-next/src/adapters/composable-cache.ts b/packages/open-next/src/adapters/composable-cache.ts new file mode 100644 index 000000000..b05b53fbf --- /dev/null +++ b/packages/open-next/src/adapters/composable-cache.ts @@ -0,0 +1,55 @@ +import type { + ComposableCacheEntry, + ComposableCacheHandler, + StoredComposableCacheEntry, +} from "types/cache"; +import { fromReadableStream, toReadableStream } from "utils/stream"; +import { debug } from "./logger"; + +export default { + async get(cacheKey: string) { + try { + // TODO: update the type of the incremental cache + const result = (await globalThis.incrementalCache.get( + cacheKey, + true, + )) as unknown as StoredComposableCacheEntry; + + debug("composable cache result", result); + + return { + ...result.value, + value: toReadableStream(result.value.value), + }; + } catch (e) { + debug("Cannot read composable cache entry"); + return undefined; + } + }, + + async set(cacheKey: string, pendingEntry: Promise) { + const entry = await pendingEntry; + const valueToStore = await fromReadableStream(entry.value); + await globalThis.incrementalCache.set(cacheKey, { + ...entry, + value: valueToStore, + } as any); + }, + + async refreshTags() { + // We don't do anything for now, do we want to do something here ??? + return; + }, + async getExpiration(...tags: string[]) { + if (globalThis.tagCache.mode === "nextMode") { + return globalThis.tagCache.getLastRevalidated(tags); + } + //TODO: Not supported for now - I'll need to figure out a way, maybe we'd want to merge both type into one + return 0; + }, + async expireTags(...tags: string[]) { + if (globalThis.tagCache.mode === "nextMode") { + return globalThis.tagCache.writeTags(tags); + } + }, +} satisfies ComposableCacheHandler; diff --git a/packages/open-next/src/build/compileCache.ts b/packages/open-next/src/build/compileCache.ts index fa30f2e3a..bf4af8412 100644 --- a/packages/open-next/src/build/compileCache.ts +++ b/packages/open-next/src/build/compileCache.ts @@ -15,7 +15,11 @@ export function compileCache( ) { const { config } = options; const ext = format === "cjs" ? "cjs" : "mjs"; - const outFile = path.join(options.buildDir, `cache.${ext}`); + const compiledCacheFile = path.join(options.buildDir, `cache.${ext}`); + const compiledComposableCacheFile = path.join( + options.buildDir, + `composable-cache.${ext}`, + ); const isAfter15 = buildHelper.compareSemver( options.nextVersion, @@ -23,11 +27,37 @@ export function compileCache( "15.0.0", ); + // Normal cache buildHelper.esbuildSync( { external: ["next", "styled-jsx", "react", "@aws-sdk/*"], entryPoints: [path.join(options.openNextDistDir, "adapters", "cache.js")], - outfile: outFile, + outfile: compiledCacheFile, + target: ["node18"], + format, + banner: { + js: [ + `globalThis.disableIncrementalCache = ${ + config.dangerous?.disableIncrementalCache ?? false + };`, + `globalThis.disableDynamoDBCache = ${ + config.dangerous?.disableTagCache ?? false + };`, + `globalThis.isNextAfter15 = ${isAfter15};`, + ].join(""), + }, + }, + options, + ); + + // Composable cache + buildHelper.esbuildSync( + { + external: ["next", "styled-jsx", "react", "@aws-sdk/*"], + entryPoints: [ + path.join(options.openNextDistDir, "adapters", "composable-cache.js"), + ], + outfile: compiledComposableCacheFile, target: ["node18"], format, banner: { @@ -44,5 +74,9 @@ export function compileCache( }, options, ); - return outFile; + + return { + cache: compiledCacheFile, + composableCache: compiledComposableCacheFile, + }; } diff --git a/packages/open-next/src/build/createServerBundle.ts b/packages/open-next/src/build/createServerBundle.ts index 694ef50c9..997166ec8 100644 --- a/packages/open-next/src/build/createServerBundle.ts +++ b/packages/open-next/src/build/createServerBundle.ts @@ -147,10 +147,16 @@ async function generateBundle( fs.mkdirSync(outPackagePath, { recursive: true }); const ext = fnOptions.runtime === "deno" ? "mjs" : "cjs"; + // Normal cache fs.copyFileSync( path.join(options.buildDir, `cache.${ext}`), path.join(outPackagePath, "cache.cjs"), ); + // Composable cache + fs.copyFileSync( + path.join(options.buildDir, `composable-cache.${ext}`), + path.join(outPackagePath, "composable-cache.cjs"), + ); if (fnOptions.runtime === "deno") { addDenoJson(outputPath, packagePath); @@ -237,6 +243,12 @@ async function generateBundle( "14.2", ); + const isAfter152 = buildHelper.compareSemver( + options.nextVersion, + ">=", + "15.2.0", + ); + const disableRouting = isBefore13413 || config.middleware?.external; const updater = new ContentUpdater(options); @@ -265,6 +277,7 @@ async function generateBundle( ...(isAfter141 ? ["experimentalIncrementalCacheHandler"] : ["stableIncrementalCache"]), + ...(isAfter152 ? [] : ["composableCache"]), ], }), diff --git a/packages/open-next/src/core/util.ts b/packages/open-next/src/core/util.ts index 876710d2a..d6b89d38a 100644 --- a/packages/open-next/src/core/util.ts +++ b/packages/open-next/src/core/util.ts @@ -25,6 +25,7 @@ overrideNextjsRequireHooks(NextConfig); applyNextjsRequireHooksOverride(); //#endOverride const cacheHandlerPath = require.resolve("./cache.cjs"); +const composableCacheHandlerPath = require.resolve("./composable-cache.cjs"); // @ts-ignore const nextServer = new NextServer.default({ //#override requestHandlerHost @@ -52,6 +53,12 @@ const nextServer = new NextServer.default({ //#override experimentalIncrementalCacheHandler incrementalCacheHandlerPath: cacheHandlerPath, //#endOverride + + //#override composableCache + cacheHandlers: { + default: composableCacheHandlerPath, + }, + //#endOverride }, }, customServer: false, diff --git a/packages/open-next/src/overrides/tagCache/dynamodb-nextMode.ts b/packages/open-next/src/overrides/tagCache/dynamodb-nextMode.ts index 7d16bebfc..1d4a5da38 100644 --- a/packages/open-next/src/overrides/tagCache/dynamodb-nextMode.ts +++ b/packages/open-next/src/overrides/tagCache/dynamodb-nextMode.ts @@ -71,6 +71,10 @@ function buildDynamoObject(tag: string, revalidatedAt?: number) { export default { name: "ddb-nextMode", mode: "nextMode", + getLastRevalidated: async (tags: string[]) => { + // Not supported for now + return 0; + }, hasBeenRevalidated: async (tags: string[], lastModified?: number) => { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return false; diff --git a/packages/open-next/src/types/cache.ts b/packages/open-next/src/types/cache.ts index ca3c4ffde..bb0bd81f5 100644 --- a/packages/open-next/src/types/cache.ts +++ b/packages/open-next/src/types/cache.ts @@ -1,3 +1,5 @@ +import type { ReadableStream } from "node:stream/web"; + interface CachedFetchValue { kind: "FETCH"; data: { diff --git a/packages/open-next/src/types/overrides.ts b/packages/open-next/src/types/overrides.ts index 8363835c4..57de7bbb2 100644 --- a/packages/open-next/src/types/overrides.ts +++ b/packages/open-next/src/types/overrides.ts @@ -129,6 +129,8 @@ Cons : */ export type NextModeTagCache = BaseTagCache & { mode: "nextMode"; + // Necessary for the composable cache + getLastRevalidated(tags: string[]): Promise; hasBeenRevalidated(tags: string[], lastModified?: number): Promise; writeTags(tags: string[]): Promise; // Optional method to get paths by tags From 6e3b99f465a9dcd169e21e427dbdfa480ad74559 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Wed, 9 Apr 2025 10:58:18 +0200 Subject: [PATCH 2/9] updated types --- packages/open-next/src/adapters/cache.ts | 16 ++++----- .../src/adapters/composable-cache.ts | 28 ++++++++------- .../src/core/routing/cacheInterceptor.ts | 2 +- .../incrementalCache/multi-tier-ddb-s3.ts | 13 +++++-- .../src/overrides/incrementalCache/s3-lite.ts | 24 +++++-------- .../src/overrides/incrementalCache/s3.ts | 8 ++--- packages/open-next/src/types/cache.ts | 26 +++++++++++++- packages/open-next/src/types/overrides.ts | 36 +++++++++++-------- packages/open-next/src/utils/cache.ts | 2 +- .../tests-unit/tests/adapters/cache.test.ts | 14 ++++---- 10 files changed, 101 insertions(+), 68 deletions(-) diff --git a/packages/open-next/src/adapters/cache.ts b/packages/open-next/src/adapters/cache.ts index e7ab10157..48a9d067d 100644 --- a/packages/open-next/src/adapters/cache.ts +++ b/packages/open-next/src/adapters/cache.ts @@ -57,7 +57,7 @@ export default class Cache { async getFetchCache(key: string, softTags?: string[], tags?: string[]) { debug("get fetch cache", { key, softTags, tags }); try { - const cachedEntry = await globalThis.incrementalCache.get(key, true); + const cachedEntry = await globalThis.incrementalCache.get(key, "fetch"); if (cachedEntry?.value === undefined) return null; @@ -107,7 +107,7 @@ export default class Cache { async getIncrementalCache(key: string): Promise { try { - const cachedEntry = await globalThis.incrementalCache.get(key, false); + const cachedEntry = await globalThis.incrementalCache.get(key, "cache"); if (!cachedEntry?.value) { return null; @@ -227,7 +227,7 @@ export default class Cache { }, revalidate, }, - false, + "cache", ); break; } @@ -248,7 +248,7 @@ export default class Cache { }, revalidate, }, - false, + "cache", ); } else { await globalThis.incrementalCache.set( @@ -259,7 +259,7 @@ export default class Cache { json: pageData, revalidate, }, - false, + "cache", ); } break; @@ -278,12 +278,12 @@ export default class Cache { }, revalidate, }, - false, + "cache", ); break; } case "FETCH": - await globalThis.incrementalCache.set(key, data, true); + await globalThis.incrementalCache.set(key, data, "fetch"); break; case "REDIRECT": await globalThis.incrementalCache.set( @@ -293,7 +293,7 @@ export default class Cache { props: data.props, revalidate, }, - false, + "cache", ); break; case "IMAGE": diff --git a/packages/open-next/src/adapters/composable-cache.ts b/packages/open-next/src/adapters/composable-cache.ts index b05b53fbf..019857b14 100644 --- a/packages/open-next/src/adapters/composable-cache.ts +++ b/packages/open-next/src/adapters/composable-cache.ts @@ -1,19 +1,17 @@ -import type { - ComposableCacheEntry, - ComposableCacheHandler, - StoredComposableCacheEntry, -} from "types/cache"; +import type { ComposableCacheEntry, ComposableCacheHandler } from "types/cache"; import { fromReadableStream, toReadableStream } from "utils/stream"; import { debug } from "./logger"; export default { async get(cacheKey: string) { try { - // TODO: update the type of the incremental cache - const result = (await globalThis.incrementalCache.get( + const result = await globalThis.incrementalCache.get( cacheKey, - true, - )) as unknown as StoredComposableCacheEntry; + "composable", + ); + if (!result || !result.value?.value) { + return undefined; + } debug("composable cache result", result); @@ -30,10 +28,14 @@ export default { async set(cacheKey: string, pendingEntry: Promise) { const entry = await pendingEntry; const valueToStore = await fromReadableStream(entry.value); - await globalThis.incrementalCache.set(cacheKey, { - ...entry, - value: valueToStore, - } as any); + await globalThis.incrementalCache.set( + cacheKey, + { + ...entry, + value: valueToStore, + }, + "composable", + ); }, async refreshTags() { diff --git a/packages/open-next/src/core/routing/cacheInterceptor.ts b/packages/open-next/src/core/routing/cacheInterceptor.ts index bb8814f12..9a2aaf88c 100644 --- a/packages/open-next/src/core/routing/cacheInterceptor.ts +++ b/packages/open-next/src/core/routing/cacheInterceptor.ts @@ -89,7 +89,7 @@ async function computeCacheControl( async function generateResult( event: InternalEvent, localizedPath: string, - cachedValue: CacheValue, + cachedValue: CacheValue<"cache">, lastModified?: number, ): Promise { debug("Returning result from experimental cache"); diff --git a/packages/open-next/src/overrides/incrementalCache/multi-tier-ddb-s3.ts b/packages/open-next/src/overrides/incrementalCache/multi-tier-ddb-s3.ts index 49c254258..5ee8bcc32 100644 --- a/packages/open-next/src/overrides/incrementalCache/multi-tier-ddb-s3.ts +++ b/packages/open-next/src/overrides/incrementalCache/multi-tier-ddb-s3.ts @@ -1,4 +1,8 @@ -import type { CacheValue, IncrementalCache } from "types/overrides"; +import type { + CacheEntryType, + CacheValue, + IncrementalCache, +} from "types/overrides"; import { customFetchClient } from "utils/fetch"; import { LRUCache } from "utils/lru"; import { debug } from "../../adapters/logger"; @@ -50,11 +54,14 @@ const buildDynamoKey = (key: string) => { */ const multiTierCache: IncrementalCache = { name: "multi-tier-ddb-s3", - async get(key: string, isFetch?: IsFetch) { + async get( + key: string, + isFetch?: CacheType, + ) { // First we check the local cache const localCacheEntry = localCache.get(key) as | { - value: CacheValue; + value: CacheValue; lastModified: number; } | undefined; diff --git a/packages/open-next/src/overrides/incrementalCache/s3-lite.ts b/packages/open-next/src/overrides/incrementalCache/s3-lite.ts index e24db620a..e2355be34 100644 --- a/packages/open-next/src/overrides/incrementalCache/s3-lite.ts +++ b/packages/open-next/src/overrides/incrementalCache/s3-lite.ts @@ -44,13 +44,10 @@ function buildS3Key(key: string, extension: Extension) { } const incrementalCache: IncrementalCache = { - async get(key, isFetch) { - const result = await awsFetch( - buildS3Key(key, isFetch ? "fetch" : "cache"), - { - method: "GET", - }, - ); + async get(key, cacheType) { + const result = await awsFetch(buildS3Key(key, cacheType ?? "cache"), { + method: "GET", + }); if (result.status === 404) { throw new IgnorableError("Not found"); @@ -66,14 +63,11 @@ const incrementalCache: IncrementalCache = { ).getTime(), }; }, - async set(key, value, isFetch): Promise { - const response = await awsFetch( - buildS3Key(key, isFetch ? "fetch" : "cache"), - { - method: "PUT", - body: JSON.stringify(value), - }, - ); + async set(key, value, cacheType): Promise { + const response = await awsFetch(buildS3Key(key, cacheType ?? "cache"), { + method: "PUT", + body: JSON.stringify(value), + }); if (response.status !== 200) { throw new RecoverableError(`Failed to set cache: ${response.status}`); } diff --git a/packages/open-next/src/overrides/incrementalCache/s3.ts b/packages/open-next/src/overrides/incrementalCache/s3.ts index 0ee7f6b51..371499209 100644 --- a/packages/open-next/src/overrides/incrementalCache/s3.ts +++ b/packages/open-next/src/overrides/incrementalCache/s3.ts @@ -40,11 +40,11 @@ function buildS3Key(key: string, extension: Extension) { } const incrementalCache: IncrementalCache = { - async get(key, isFetch) { + async get(key, cacheType) { const result = await s3Client.send( new GetObjectCommand({ Bucket: CACHE_BUCKET_NAME, - Key: buildS3Key(key, isFetch ? "fetch" : "cache"), + Key: buildS3Key(key, cacheType ?? "cache"), }), ); @@ -56,11 +56,11 @@ const incrementalCache: IncrementalCache = { lastModified: result.LastModified?.getTime(), }; }, - async set(key, value, isFetch): Promise { + async set(key, value, cacheType): Promise { await s3Client.send( new PutObjectCommand({ Bucket: CACHE_BUCKET_NAME, - Key: buildS3Key(key, isFetch ? "fetch" : "cache"), + Key: buildS3Key(key, cacheType ?? "cache"), Body: JSON.stringify(value), }), ); diff --git a/packages/open-next/src/types/cache.ts b/packages/open-next/src/types/cache.ts index bb0bd81f5..07c226803 100644 --- a/packages/open-next/src/types/cache.ts +++ b/packages/open-next/src/types/cache.ts @@ -81,7 +81,7 @@ export interface CacheHandlerValue { value: IncrementalCacheValue | null; } -export type Extension = "cache" | "fetch"; +export type Extension = "cache" | "fetch" | "composable"; type MetaHeaders = { "x-next-cache-tags"?: string; @@ -141,3 +141,27 @@ export type IncrementalCacheContext = | SetIncrementalCacheContext | SetIncrementalFetchCacheContext | SetIncrementalResponseCacheContext; + +export interface ComposableCacheEntry { + value: ReadableStream; + tags: string[]; + stale: number; + timestamp: number; + expire: number; + revalidate: number; +} + +export type StoredComposableCacheEntry = Omit & { + value: string; +}; + +export interface ComposableCacheHandler { + get(cacheKey: string): Promise; + set( + cacheKey: string, + pendingEntry: Promise, + ): Promise; + refreshTags(): Promise; + getExpiration(...tags: string[]): Promise; + expireTags(...tags: string[]): Promise; +} diff --git a/packages/open-next/src/types/overrides.ts b/packages/open-next/src/types/overrides.ts index 57de7bbb2..4d8eb02d9 100644 --- a/packages/open-next/src/types/overrides.ts +++ b/packages/open-next/src/types/overrides.ts @@ -1,6 +1,7 @@ import type { Readable } from "node:stream"; -import type { Meta } from "types/cache"; +import type { Extension, Meta, StoredComposableCacheEntry } from "types/cache"; + import type { BaseEventOrResult, BaseOverride, @@ -77,24 +78,29 @@ export type WithLastModified = { value?: T; }; -export type CacheValue = (IsFetch extends true - ? CachedFetchValue - : CachedFile) & { - /** - * This is available for page cache entry, but only at runtime. - */ - revalidate?: number | false; -}; +export type CacheEntryType = Extension; + +export type CacheValue = + (CacheType extends "fetch" + ? CachedFetchValue + : CacheType extends "cache" + ? CachedFile + : StoredComposableCacheEntry) & { + /** + * This is available for page cache entry, but only at runtime. + */ + revalidate?: number | false; + }; export type IncrementalCache = { - get( + get( key: string, - isFetch?: IsFetch, - ): Promise> | null>; - set( + cacheType?: CacheType, + ): Promise> | null>; + set( key: string, - value: CacheValue, - isFetch?: IsFetch, + value: CacheValue, + isFetch?: CacheType, ): Promise; delete(key: string): Promise; name: string; diff --git a/packages/open-next/src/utils/cache.ts b/packages/open-next/src/utils/cache.ts index 98c028004..5072e4bef 100644 --- a/packages/open-next/src/utils/cache.ts +++ b/packages/open-next/src/utils/cache.ts @@ -28,7 +28,7 @@ export async function hasBeenRevalidated( return _lastModified === -1; } -export function getTagsFromValue(value?: CacheValue) { +export function getTagsFromValue(value?: CacheValue<"cache">) { if (!value) { return []; } diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index 4dff3166c..e4e3977fd 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -361,7 +361,7 @@ describe("CacheHandler", () => { expect(incrementalCache.set).toHaveBeenCalledWith( "key", { type: "route", body: "{}", meta: { status: 200, headers: {} } }, - false, + "cache", ); }); @@ -382,7 +382,7 @@ describe("CacheHandler", () => { body: Buffer.from("{}").toString("base64"), meta: { status: 200, headers: { "content-type": "image/png" } }, }, - false, + "cache", ); }); @@ -402,7 +402,7 @@ describe("CacheHandler", () => { html: "", json: {}, }, - false, + "cache", ); }); @@ -423,7 +423,7 @@ describe("CacheHandler", () => { rsc: "rsc", meta: { status: 200, headers: {} }, }, - false, + "cache", ); }); @@ -444,7 +444,7 @@ describe("CacheHandler", () => { rsc: "rsc", meta: { status: 200, headers: {} }, }, - false, + "cache", ); }); @@ -474,7 +474,7 @@ describe("CacheHandler", () => { }, revalidate: 60, }, - true, + "fetch", ); }); @@ -487,7 +487,7 @@ describe("CacheHandler", () => { type: "redirect", props: {}, }, - false, + "cache", ); }); From 761d42454832239827b0637ac4c29d66ae475f55 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Wed, 9 Apr 2025 13:05:14 +0200 Subject: [PATCH 3/9] make it work with original mode --- .../src/adapters/composable-cache.ts | 54 ++++++++++++++++++- .../src/overrides/tagCache/fs-dev.ts | 3 +- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/open-next/src/adapters/composable-cache.ts b/packages/open-next/src/adapters/composable-cache.ts index 019857b14..228d8bd3b 100644 --- a/packages/open-next/src/adapters/composable-cache.ts +++ b/packages/open-next/src/adapters/composable-cache.ts @@ -15,6 +15,28 @@ export default { debug("composable cache result", result); + // We need to check if the tags associated with this entry has been revalidated + if ( + globalThis.tagCache.mode === "nextMode" && + result.value.tags.length > 0 + ) { + const hasBeenRevalidated = await globalThis.tagCache.hasBeenRevalidated( + result.value.tags, + result.lastModified, + ); + if (hasBeenRevalidated) return undefined; + } else if ( + globalThis.tagCache.mode === "original" || + globalThis.tagCache.mode === undefined + ) { + const hasBeenRevalidated = + (await globalThis.tagCache.getLastModified( + cacheKey, + result.lastModified, + )) === -1; + if (hasBeenRevalidated) return undefined; + } + return { ...result.value, value: toReadableStream(result.value.value), @@ -36,6 +58,15 @@ export default { }, "composable", ); + if (globalThis.tagCache.mode === "original") { + const storedTags = await globalThis.tagCache.getByPath(cacheKey); + const tagsToWrite = entry.tags.filter((tag) => !storedTags.includes(tag)); + if (tagsToWrite.length > 0) { + await globalThis.tagCache.writeTags( + tagsToWrite.map((tag) => ({ tag, path: cacheKey })), + ); + } + } }, async refreshTags() { @@ -46,12 +77,33 @@ export default { if (globalThis.tagCache.mode === "nextMode") { return globalThis.tagCache.getLastRevalidated(tags); } - //TODO: Not supported for now - I'll need to figure out a way, maybe we'd want to merge both type into one + // We always return 0 here, original tag cache are handled directly in the get part + // TODO: We need to test this more, i'm not entirely sure that this is working as expected return 0; }, async expireTags(...tags: string[]) { if (globalThis.tagCache.mode === "nextMode") { return globalThis.tagCache.writeTags(tags); } + const tagCache = globalThis.tagCache; + const revalidatedAt = Date.now(); + // For the original mode, we have more work to do here. + // We need to find all paths linked to to these tags + const pathsToUpdate = await Promise.all( + tags.map(async (tag) => { + const paths = await tagCache.getByTag(tag); + return paths.map((path) => ({ + path, + tag, + revalidatedAt, + })); + }), + ); + // We need to deduplicate paths, we use a set for that + const setToWrite = new Set<{ path: string; tag: string }>(); + for (const entry of pathsToUpdate.flat()) { + setToWrite.add(entry); + } + await globalThis.tagCache.writeTags(Array.from(setToWrite)); }, } satisfies ComposableCacheHandler; diff --git a/packages/open-next/src/overrides/tagCache/fs-dev.ts b/packages/open-next/src/overrides/tagCache/fs-dev.ts index 4de4d0a75..f6f61e992 100644 --- a/packages/open-next/src/overrides/tagCache/fs-dev.ts +++ b/packages/open-next/src/overrides/tagCache/fs-dev.ts @@ -2,6 +2,7 @@ import type { TagCache } from "types/overrides"; import fs from "node:fs"; +// TODO: fix this for monorepo const tagFile = "../../dynamodb-provider/dynamodb-cache.json"; const tagContent = fs.readFileSync(tagFile, "utf-8"); @@ -44,7 +45,7 @@ const tagCache: TagCache = { newTags.map((tag) => ({ tag: { S: tag.tag }, path: { S: tag.path }, - revalidatedAt: { N: String(tag.revalidatedAt) }, + revalidatedAt: { N: String(tag.revalidatedAt ?? 1) }, })), ); }, From d367f83de9215be9cf98b4a69561b23087b3c486 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Fri, 18 Apr 2025 15:09:05 +0200 Subject: [PATCH 4/9] add e2e test --- examples/experimental/next.config.ts | 1 + .../src/app/api/revalidate/route.ts | 6 +++ .../src/app/use-cache/isr/page.tsx | 17 +++++++ .../experimental/src/app/use-cache/layout.tsx | 13 ++++++ .../src/app/use-cache/ssr/page.tsx | 20 ++++++++ .../experimental/src/components/cached.tsx | 24 ++++++++++ .../src/adapters/composable-cache.ts | 6 +++ packages/open-next/src/types/cache.ts | 4 ++ .../tests/experimental/use-cache.test.ts | 46 +++++++++++++++++++ 9 files changed, 137 insertions(+) create mode 100644 examples/experimental/src/app/api/revalidate/route.ts create mode 100644 examples/experimental/src/app/use-cache/isr/page.tsx create mode 100644 examples/experimental/src/app/use-cache/layout.tsx create mode 100644 examples/experimental/src/app/use-cache/ssr/page.tsx create mode 100644 examples/experimental/src/components/cached.tsx create mode 100644 packages/tests-e2e/tests/experimental/use-cache.test.ts diff --git a/examples/experimental/next.config.ts b/examples/experimental/next.config.ts index c3fcdb402..c6ebc28eb 100644 --- a/examples/experimental/next.config.ts +++ b/examples/experimental/next.config.ts @@ -10,6 +10,7 @@ const nextConfig: NextConfig = { experimental: { ppr: "incremental", nodeMiddleware: true, + dynamicIO: true, }, }; diff --git a/examples/experimental/src/app/api/revalidate/route.ts b/examples/experimental/src/app/api/revalidate/route.ts new file mode 100644 index 000000000..da6b1e027 --- /dev/null +++ b/examples/experimental/src/app/api/revalidate/route.ts @@ -0,0 +1,6 @@ +import { revalidateTag } from "next/cache"; + +export function GET() { + revalidateTag("fullyTagged"); + return new Response("DONE"); +} diff --git a/examples/experimental/src/app/use-cache/isr/page.tsx b/examples/experimental/src/app/use-cache/isr/page.tsx new file mode 100644 index 000000000..dd02f8a63 --- /dev/null +++ b/examples/experimental/src/app/use-cache/isr/page.tsx @@ -0,0 +1,17 @@ +import { FullyCachedComponent, ISRComponent } from "@/components/cached"; +import { Suspense } from "react"; + +export default async function Page() { + // Not working for now, need a patch in next to disable full revalidation during ISR revalidation + return ( +
+

Cache

+ Loading...

}> + +
+ Loading...

}> + +
+
+ ); +} diff --git a/examples/experimental/src/app/use-cache/layout.tsx b/examples/experimental/src/app/use-cache/layout.tsx new file mode 100644 index 000000000..b21b82fe6 --- /dev/null +++ b/examples/experimental/src/app/use-cache/layout.tsx @@ -0,0 +1,13 @@ +import { Suspense } from "react"; + +export default function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ Loading...

}>{children}
+
+ ); +} diff --git a/examples/experimental/src/app/use-cache/ssr/page.tsx b/examples/experimental/src/app/use-cache/ssr/page.tsx new file mode 100644 index 000000000..41a413d1b --- /dev/null +++ b/examples/experimental/src/app/use-cache/ssr/page.tsx @@ -0,0 +1,20 @@ +import { FullyCachedComponent, ISRComponent } from "@/components/cached"; +import { headers } from "next/headers"; +import { Suspense } from "react"; + +export default async function Page() { + // To opt into SSR + const _headers = await headers(); + return ( +
+

Cache

+

{_headers.get("accept") ?? "No accept headers"}

+ Loading...

}> + +
+ Loading...

}> + +
+
+ ); +} diff --git a/examples/experimental/src/components/cached.tsx b/examples/experimental/src/components/cached.tsx new file mode 100644 index 000000000..34db53992 --- /dev/null +++ b/examples/experimental/src/components/cached.tsx @@ -0,0 +1,24 @@ +import { unstable_cacheTag, unstable_cacheLife } from "next/cache"; + +export async function FullyCachedComponent() { + "use cache"; + unstable_cacheTag("fullyTagged"); + return ( +
+

{Date.now()}

+
+ ); +} + +export async function ISRComponent() { + "use cache"; + unstable_cacheLife({ + stale: 1, + revalidate: 5, + }); + return ( +
+

{Date.now()}

+
+ ); +} diff --git a/packages/open-next/src/adapters/composable-cache.ts b/packages/open-next/src/adapters/composable-cache.ts index 228d8bd3b..72d624569 100644 --- a/packages/open-next/src/adapters/composable-cache.ts +++ b/packages/open-next/src/adapters/composable-cache.ts @@ -106,4 +106,10 @@ export default { } await globalThis.tagCache.writeTags(Array.from(setToWrite)); }, + + // This one is necessary for older versions of next + async receiveExpiredTags(...tags: string[]) { + // This function does absolutely nothing + return; + }, } satisfies ComposableCacheHandler; diff --git a/packages/open-next/src/types/cache.ts b/packages/open-next/src/types/cache.ts index 07c226803..26b5b396e 100644 --- a/packages/open-next/src/types/cache.ts +++ b/packages/open-next/src/types/cache.ts @@ -164,4 +164,8 @@ export interface ComposableCacheHandler { refreshTags(): Promise; getExpiration(...tags: string[]): Promise; expireTags(...tags: string[]): Promise; + /** + * This function is only there for older versions and do nothing + */ + receiveExpiredTags(...tags: string[]): Promise; } diff --git a/packages/tests-e2e/tests/experimental/use-cache.test.ts b/packages/tests-e2e/tests/experimental/use-cache.test.ts new file mode 100644 index 000000000..1c750baf3 --- /dev/null +++ b/packages/tests-e2e/tests/experimental/use-cache.test.ts @@ -0,0 +1,46 @@ +import { test, expect } from "@playwright/test"; + +test("cached component should work in ssr", async ({ page }) => { + await page.goto("/use-cache/ssr"); + let fullyCachedElt = page.getByTestId("fullyCached"); + let isrElt = page.getByTestId("isr"); + await expect(fullyCachedElt).toBeVisible(); + await expect(isrElt).toBeVisible(); + + const initialFullyCachedText = await fullyCachedElt.textContent(); + const initialIsrText = await isrElt.textContent(); + + let isrText = initialIsrText; + + do { + await page.reload(); + fullyCachedElt = page.getByTestId("fullyCached"); + isrElt = page.getByTestId("isr"); + await expect(fullyCachedElt).toBeVisible(); + await expect(isrElt).toBeVisible(); + isrText = await isrElt.textContent(); + await page.waitForTimeout(1000); + } while (isrText === initialIsrText); + + expect(fullyCachedElt).toHaveText(initialFullyCachedText ?? ""); + expect(isrElt).not.toHaveText(initialIsrText ?? ""); +}); + +test("revalidateTag should work for fullyCached component", async ({ + page, + request, +}) => { + await page.goto("/use-cache/ssr"); + const fullyCachedElt = page.getByTestId("fullyCached"); + await expect(fullyCachedElt).toBeVisible(); + + const initialFullyCachedText = await fullyCachedElt.textContent(); + + const resp = await request.get("/api/revalidate"); + expect(resp.status()).toEqual(200); + expect(await resp.text()).toEqual("DONE"); + + await page.reload(); + await expect(fullyCachedElt).toBeVisible(); + expect(fullyCachedElt).not.toHaveText(initialFullyCachedText ?? ""); +}); From 9ee620ba2cdf2ff88067ed28aae2e841822dbd28 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Fri, 18 Apr 2025 15:09:30 +0200 Subject: [PATCH 5/9] lint --- examples/experimental/src/components/cached.tsx | 2 +- packages/tests-e2e/tests/experimental/use-cache.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/experimental/src/components/cached.tsx b/examples/experimental/src/components/cached.tsx index 34db53992..7abaa010c 100644 --- a/examples/experimental/src/components/cached.tsx +++ b/examples/experimental/src/components/cached.tsx @@ -1,4 +1,4 @@ -import { unstable_cacheTag, unstable_cacheLife } from "next/cache"; +import { unstable_cacheLife, unstable_cacheTag } from "next/cache"; export async function FullyCachedComponent() { "use cache"; diff --git a/packages/tests-e2e/tests/experimental/use-cache.test.ts b/packages/tests-e2e/tests/experimental/use-cache.test.ts index 1c750baf3..4847bdb28 100644 --- a/packages/tests-e2e/tests/experimental/use-cache.test.ts +++ b/packages/tests-e2e/tests/experimental/use-cache.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; test("cached component should work in ssr", async ({ page }) => { await page.goto("/use-cache/ssr"); From b2a7d7bc0bb168590a023b5708b3cc44d66303cc Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Wed, 23 Apr 2025 10:26:41 +0200 Subject: [PATCH 6/9] patch use cache for ISR --- .../open-next/src/build/createServerBundle.ts | 2 + .../src/build/patch/patches/index.ts | 1 + .../build/patch/patches/patchFetchCacheISR.ts | 40 +++++++++++++++ .../tests/experimental/use-cache.test.ts | 49 +++++++++++++++++-- .../patch/patches/patchFetchCacheISR.test.ts | 48 ++++++++++++++++++ 5 files changed, 136 insertions(+), 4 deletions(-) diff --git a/packages/open-next/src/build/createServerBundle.ts b/packages/open-next/src/build/createServerBundle.ts index 997166ec8..3582f5549 100644 --- a/packages/open-next/src/build/createServerBundle.ts +++ b/packages/open-next/src/build/createServerBundle.ts @@ -28,6 +28,7 @@ import { patchFetchCacheSetMissingWaitUntil, patchNextServer, patchUnstableCacheForISR, + patchUseCacheForISR, } from "./patch/patches/index.js"; interface CodeCustomization { @@ -212,6 +213,7 @@ async function generateBundle( patchNextServer, patchEnvVars, patchBackgroundRevalidation, + patchUseCacheForISR, ...additionalCodePatches, ]); diff --git a/packages/open-next/src/build/patch/patches/index.ts b/packages/open-next/src/build/patch/patches/index.ts index 22ef817ba..bd46d6532 100644 --- a/packages/open-next/src/build/patch/patches/index.ts +++ b/packages/open-next/src/build/patch/patches/index.ts @@ -3,6 +3,7 @@ export { patchNextServer } from "./patchNextServer.js"; export { patchFetchCacheForISR, patchUnstableCacheForISR, + patchUseCacheForISR, } from "./patchFetchCacheISR.js"; export { patchFetchCacheSetMissingWaitUntil } from "./patchFetchCacheWaitUntil.js"; export { patchBackgroundRevalidation } from "./patchBackgroundRevalidation.js"; diff --git a/packages/open-next/src/build/patch/patches/patchFetchCacheISR.ts b/packages/open-next/src/build/patch/patches/patchFetchCacheISR.ts index 17ccea628..ca858c0fd 100644 --- a/packages/open-next/src/build/patch/patches/patchFetchCacheISR.ts +++ b/packages/open-next/src/build/patch/patches/patchFetchCacheISR.ts @@ -78,6 +78,29 @@ fix: ($STORE_OR_CACHE.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation) `; +export const useCacheRule = ` +rule: + kind: member_expression + pattern: $STORE_OR_CACHE.isOnDemandRevalidate + inside: + kind: binary_expression + has: + kind: member_expression + pattern: $STORE_OR_CACHE.isDraftMode + inside: + kind: if_statement + stopBy: end + has: + kind: return_statement + any: + - has: + kind: 'true' + - has: + regex: '!0' + stopBy: end +fix: + '($STORE_OR_CACHE.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation)'`; + export const patchFetchCacheForISR: CodePatcher = { name: "patch-fetch-cache-for-isr", patches: [ @@ -111,3 +134,20 @@ export const patchUnstableCacheForISR: CodePatcher = { }, ], }; + +export const patchUseCacheForISR: CodePatcher = { + name: "patch-use-cache-for-isr", + patches: [ + { + versions: ">=15.3.0", + field: { + pathFilter: getCrossPlatformPathRegex( + String.raw`(server/chunks/.*\.js|.*\.runtime\..*\.js|use-cache/use-cache-wrapper\.js)$`, + { escape: false }, + ), + contentFilter: /\.isOnDemandRevalidate/, + patchCode: createPatchCode(useCacheRule, Lang.JavaScript), + }, + }, + ], +}; diff --git a/packages/tests-e2e/tests/experimental/use-cache.test.ts b/packages/tests-e2e/tests/experimental/use-cache.test.ts index 4847bdb28..8f4111717 100644 --- a/packages/tests-e2e/tests/experimental/use-cache.test.ts +++ b/packages/tests-e2e/tests/experimental/use-cache.test.ts @@ -21,9 +21,8 @@ test("cached component should work in ssr", async ({ page }) => { isrText = await isrElt.textContent(); await page.waitForTimeout(1000); } while (isrText === initialIsrText); - - expect(fullyCachedElt).toHaveText(initialFullyCachedText ?? ""); - expect(isrElt).not.toHaveText(initialIsrText ?? ""); + const fullyCachedText = await fullyCachedElt.textContent(); + expect(fullyCachedText).toEqual(initialFullyCachedText); }); test("revalidateTag should work for fullyCached component", async ({ @@ -42,5 +41,47 @@ test("revalidateTag should work for fullyCached component", async ({ await page.reload(); await expect(fullyCachedElt).toBeVisible(); - expect(fullyCachedElt).not.toHaveText(initialFullyCachedText ?? ""); + const newFullyCachedText = await fullyCachedElt.textContent(); + expect(newFullyCachedText).not.toEqual(initialFullyCachedText); +}); + +test("cached component should work in isr", async ({ page }) => { + await page.goto("/use-cache/isr"); + + let fullyCachedElt = page.getByTestId("fullyCached"); + let isrElt = page.getByTestId("isr"); + + await expect(fullyCachedElt).toBeVisible(); + await expect(isrElt).toBeVisible(); + + let initialFullyCachedText = await fullyCachedElt.textContent(); + let initialIsrText = await isrElt.textContent(); + + // We have to force reload until ISR has triggered at least once, otherwise the test will be flakey + + let isrText = initialIsrText; + + while (isrText === initialIsrText) { + await page.reload(); + isrElt = page.getByTestId("isr"); + fullyCachedElt = page.getByTestId("fullyCached"); + await expect(isrElt).toBeVisible(); + isrText = await isrElt.textContent(); + await expect(fullyCachedElt).toBeVisible(); + initialFullyCachedText = await fullyCachedElt.textContent(); + await page.waitForTimeout(1000); + } + initialIsrText = isrText; + + do { + await page.reload(); + fullyCachedElt = page.getByTestId("fullyCached"); + isrElt = page.getByTestId("isr"); + await expect(fullyCachedElt).toBeVisible(); + await expect(isrElt).toBeVisible(); + isrText = await isrElt.textContent(); + await page.waitForTimeout(1000); + } while (isrText === initialIsrText); + const fullyCachedText = await fullyCachedElt.textContent(); + expect(fullyCachedText).toEqual(initialFullyCachedText); }); diff --git a/packages/tests-unit/tests/build/patch/patches/patchFetchCacheISR.test.ts b/packages/tests-unit/tests/build/patch/patches/patchFetchCacheISR.test.ts index 649c5cc13..e6d421aee 100644 --- a/packages/tests-unit/tests/build/patch/patches/patchFetchCacheISR.test.ts +++ b/packages/tests-unit/tests/build/patch/patches/patchFetchCacheISR.test.ts @@ -2,6 +2,7 @@ import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js"; import { fetchRule, unstable_cacheRule, + useCacheRule, } from "@opennextjs/aws/build/patch/patches/patchFetchCacheISR.js"; import { describe } from "vitest"; @@ -54,6 +55,24 @@ const patchFetchCacheCodeMinifiedNext15 = ` let t=P.isOnDemandRevalidate?null:await V.get(n,{kind:l.IncrementalCacheKind.FETCH,revalidate:_,fetchUrl:y,fetchIdx:X,tags:N,softTags:C}); `; +const patchUseCacheUnminified = ` +function shouldForceRevalidate(workStore, workUnitStore) { + if (workStore.isOnDemandRevalidate || workStore.isDraftMode) { + return true; + } + if (workStore.dev && workUnitStore) { + if (workUnitStore.type === 'request') { + return workUnitStore.headers.get('cache-control') === 'no-cache'; + } + if (workUnitStore.type === 'cache') { + return workUnitStore.forceRevalidate; + } + } + return false; +}`; +const patchUseCacheMinified = ` +function D(e,t){if(e.isOnDemandRevalidate||e.isDraftMode)return!0;if(e.dev&&t){if("request"===t.type)return"no-cache"===t.headers.get("cache-control");if("cache"===t.type)return t.forceRevalidate}return!1}`; + describe("patchUnstableCacheForISR", () => { test("on unminified code", async () => { expect( @@ -124,3 +143,32 @@ describe("patchFetchCacheISR", () => { }); //TODO: Add test for Next 14.2.24 }); + +describe("patchUseCache", () => { + test("on unminified code", async () => { + expect( + patchCode(patchUseCacheUnminified, useCacheRule), + ).toMatchInlineSnapshot(` +"function shouldForceRevalidate(workStore, workUnitStore) { + if ((workStore.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation) || workStore.isDraftMode) { + return true; + } + if (workStore.dev && workUnitStore) { + if (workUnitStore.type === 'request') { + return workUnitStore.headers.get('cache-control') === 'no-cache'; + } + if (workUnitStore.type === 'cache') { + return workUnitStore.forceRevalidate; + } + } + return false; +}"`); + }); + test("on minified code", async () => { + expect( + patchCode(patchUseCacheMinified, useCacheRule), + ).toMatchInlineSnapshot(` +"function D(e,t){if((e.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation)||e.isDraftMode)return!0;if(e.dev&&t){if("request"===t.type)return"no-cache"===t.headers.get("cache-control");if("cache"===t.type)return t.forceRevalidate}return!1}" +`); + }); +}); From 76ce21aaaf4785a365b95a4b85586039fdb66090 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Tue, 29 Apr 2025 10:49:00 +0200 Subject: [PATCH 7/9] review fix --- packages/open-next/src/adapters/composable-cache.ts | 2 +- packages/open-next/src/build/compileCache.ts | 11 ++++++----- .../src/build/patch/patches/patchFetchCacheISR.ts | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/open-next/src/adapters/composable-cache.ts b/packages/open-next/src/adapters/composable-cache.ts index 72d624569..00b3847bd 100644 --- a/packages/open-next/src/adapters/composable-cache.ts +++ b/packages/open-next/src/adapters/composable-cache.ts @@ -9,7 +9,7 @@ export default { cacheKey, "composable", ); - if (!result || !result.value?.value) { + if (!result?.value?.value) { return undefined; } diff --git a/packages/open-next/src/build/compileCache.ts b/packages/open-next/src/build/compileCache.ts index bf4af8412..7e390fdf5 100644 --- a/packages/open-next/src/build/compileCache.ts +++ b/packages/open-next/src/build/compileCache.ts @@ -7,7 +7,7 @@ import * as buildHelper from "./helper.js"; * * @param options Build options. * @param format Output format. - * @returns The path to the compiled file. + * @returns An object containing the paths to the compiled cache and composable cache files. */ export function compileCache( options: buildHelper.BuildOptions, @@ -16,10 +16,6 @@ export function compileCache( const { config } = options; const ext = format === "cjs" ? "cjs" : "mjs"; const compiledCacheFile = path.join(options.buildDir, `cache.${ext}`); - const compiledComposableCacheFile = path.join( - options.buildDir, - `composable-cache.${ext}`, - ); const isAfter15 = buildHelper.compareSemver( options.nextVersion, @@ -50,6 +46,11 @@ export function compileCache( options, ); + const compiledComposableCacheFile = path.join( + options.buildDir, + `composable-cache.${ext}`, + ); + // Composable cache buildHelper.esbuildSync( { diff --git a/packages/open-next/src/build/patch/patches/patchFetchCacheISR.ts b/packages/open-next/src/build/patch/patches/patchFetchCacheISR.ts index ca858c0fd..d117045cb 100644 --- a/packages/open-next/src/build/patch/patches/patchFetchCacheISR.ts +++ b/packages/open-next/src/build/patch/patches/patchFetchCacheISR.ts @@ -142,7 +142,7 @@ export const patchUseCacheForISR: CodePatcher = { versions: ">=15.3.0", field: { pathFilter: getCrossPlatformPathRegex( - String.raw`(server/chunks/.*\.js|.*\.runtime\..*\.js|use-cache/use-cache-wrapper\.js)$`, + String.raw`(server/chunks/.*\.js|\.runtime\..*\.js|use-cache/use-cache-wrapper\.js)$`, { escape: false }, ), contentFilter: /\.isOnDemandRevalidate/, From eaaf1ae46faeaaa43d71c4deadf3b430c8a701d5 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Tue, 29 Apr 2025 10:59:16 +0200 Subject: [PATCH 8/9] changeset --- .changeset/sour-pandas-buy.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .changeset/sour-pandas-buy.md diff --git a/.changeset/sour-pandas-buy.md b/.changeset/sour-pandas-buy.md new file mode 100644 index 000000000..228a050fb --- /dev/null +++ b/.changeset/sour-pandas-buy.md @@ -0,0 +1,31 @@ +--- +"@opennextjs/aws": minor +--- + +Introduce support for the composable cache + +BREAKING CHANGE: The interface for the Incremental cache has changed. The new interface use a Cache type instead of a boolean to distinguish between the different types of caches. It also includes a new Cache type for the composable cache. The new interface is as follows: + +```ts +export type CacheEntryType = "cache" | "fetch" | "composable"; + +export type IncrementalCache = { + get( + key: string, + cacheType?: CacheType, + ): Promise> | null>; + set( + key: string, + value: CacheValue, + isFetch?: CacheType, + ): Promise; + delete(key: string): Promise; + name: string; +}; +``` + +NextModeTagCache also get a new function `getLastRevalidated` used for the composable cache: + +```ts + getLastRevalidated(tags: string[]): Promise; +``` \ No newline at end of file From 4d83b933e67aa8a9d144058484b6ff967eac668c Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Tue, 29 Apr 2025 11:08:37 +0200 Subject: [PATCH 9/9] move test inside describe --- .../tests/experimental/use-cache.test.ts | 148 +++++++++--------- 1 file changed, 75 insertions(+), 73 deletions(-) diff --git a/packages/tests-e2e/tests/experimental/use-cache.test.ts b/packages/tests-e2e/tests/experimental/use-cache.test.ts index 8f4111717..378c496b2 100644 --- a/packages/tests-e2e/tests/experimental/use-cache.test.ts +++ b/packages/tests-e2e/tests/experimental/use-cache.test.ts @@ -1,87 +1,89 @@ import { expect, test } from "@playwright/test"; -test("cached component should work in ssr", async ({ page }) => { - await page.goto("/use-cache/ssr"); - let fullyCachedElt = page.getByTestId("fullyCached"); - let isrElt = page.getByTestId("isr"); - await expect(fullyCachedElt).toBeVisible(); - await expect(isrElt).toBeVisible(); - - const initialFullyCachedText = await fullyCachedElt.textContent(); - const initialIsrText = await isrElt.textContent(); - - let isrText = initialIsrText; - - do { - await page.reload(); - fullyCachedElt = page.getByTestId("fullyCached"); - isrElt = page.getByTestId("isr"); +test.describe("Composable Cache", () => { + test("cached component should work in ssr", async ({ page }) => { + await page.goto("/use-cache/ssr"); + let fullyCachedElt = page.getByTestId("fullyCached"); + let isrElt = page.getByTestId("isr"); await expect(fullyCachedElt).toBeVisible(); await expect(isrElt).toBeVisible(); - isrText = await isrElt.textContent(); - await page.waitForTimeout(1000); - } while (isrText === initialIsrText); - const fullyCachedText = await fullyCachedElt.textContent(); - expect(fullyCachedText).toEqual(initialFullyCachedText); -}); - -test("revalidateTag should work for fullyCached component", async ({ - page, - request, -}) => { - await page.goto("/use-cache/ssr"); - const fullyCachedElt = page.getByTestId("fullyCached"); - await expect(fullyCachedElt).toBeVisible(); - - const initialFullyCachedText = await fullyCachedElt.textContent(); - - const resp = await request.get("/api/revalidate"); - expect(resp.status()).toEqual(200); - expect(await resp.text()).toEqual("DONE"); - - await page.reload(); - await expect(fullyCachedElt).toBeVisible(); - const newFullyCachedText = await fullyCachedElt.textContent(); - expect(newFullyCachedText).not.toEqual(initialFullyCachedText); -}); -test("cached component should work in isr", async ({ page }) => { - await page.goto("/use-cache/isr"); - - let fullyCachedElt = page.getByTestId("fullyCached"); - let isrElt = page.getByTestId("isr"); - - await expect(fullyCachedElt).toBeVisible(); - await expect(isrElt).toBeVisible(); - - let initialFullyCachedText = await fullyCachedElt.textContent(); - let initialIsrText = await isrElt.textContent(); + const initialFullyCachedText = await fullyCachedElt.textContent(); + const initialIsrText = await isrElt.textContent(); + + let isrText = initialIsrText; + + do { + await page.reload(); + fullyCachedElt = page.getByTestId("fullyCached"); + isrElt = page.getByTestId("isr"); + await expect(fullyCachedElt).toBeVisible(); + await expect(isrElt).toBeVisible(); + isrText = await isrElt.textContent(); + await page.waitForTimeout(1000); + } while (isrText === initialIsrText); + const fullyCachedText = await fullyCachedElt.textContent(); + expect(fullyCachedText).toEqual(initialFullyCachedText); + }); + + test("revalidateTag should work for fullyCached component", async ({ + page, + request, + }) => { + await page.goto("/use-cache/ssr"); + const fullyCachedElt = page.getByTestId("fullyCached"); + await expect(fullyCachedElt).toBeVisible(); - // We have to force reload until ISR has triggered at least once, otherwise the test will be flakey + const initialFullyCachedText = await fullyCachedElt.textContent(); - let isrText = initialIsrText; + const resp = await request.get("/api/revalidate"); + expect(resp.status()).toEqual(200); + expect(await resp.text()).toEqual("DONE"); - while (isrText === initialIsrText) { await page.reload(); - isrElt = page.getByTestId("isr"); - fullyCachedElt = page.getByTestId("fullyCached"); - await expect(isrElt).toBeVisible(); - isrText = await isrElt.textContent(); await expect(fullyCachedElt).toBeVisible(); - initialFullyCachedText = await fullyCachedElt.textContent(); - await page.waitForTimeout(1000); - } - initialIsrText = isrText; + const newFullyCachedText = await fullyCachedElt.textContent(); + expect(newFullyCachedText).not.toEqual(initialFullyCachedText); + }); + + test("cached component should work in isr", async ({ page }) => { + await page.goto("/use-cache/isr"); + + let fullyCachedElt = page.getByTestId("fullyCached"); + let isrElt = page.getByTestId("isr"); - do { - await page.reload(); - fullyCachedElt = page.getByTestId("fullyCached"); - isrElt = page.getByTestId("isr"); await expect(fullyCachedElt).toBeVisible(); await expect(isrElt).toBeVisible(); - isrText = await isrElt.textContent(); - await page.waitForTimeout(1000); - } while (isrText === initialIsrText); - const fullyCachedText = await fullyCachedElt.textContent(); - expect(fullyCachedText).toEqual(initialFullyCachedText); + + let initialFullyCachedText = await fullyCachedElt.textContent(); + let initialIsrText = await isrElt.textContent(); + + // We have to force reload until ISR has triggered at least once, otherwise the test will be flakey + + let isrText = initialIsrText; + + while (isrText === initialIsrText) { + await page.reload(); + isrElt = page.getByTestId("isr"); + fullyCachedElt = page.getByTestId("fullyCached"); + await expect(isrElt).toBeVisible(); + isrText = await isrElt.textContent(); + await expect(fullyCachedElt).toBeVisible(); + initialFullyCachedText = await fullyCachedElt.textContent(); + await page.waitForTimeout(1000); + } + initialIsrText = isrText; + + do { + await page.reload(); + fullyCachedElt = page.getByTestId("fullyCached"); + isrElt = page.getByTestId("isr"); + await expect(fullyCachedElt).toBeVisible(); + await expect(isrElt).toBeVisible(); + isrText = await isrElt.textContent(); + await page.waitForTimeout(1000); + } while (isrText === initialIsrText); + const fullyCachedText = await fullyCachedElt.textContent(); + expect(fullyCachedText).toEqual(initialFullyCachedText); + }); });