diff --git a/.changeset/fix-cache-interceptor-index-route.md b/.changeset/fix-cache-interceptor-index-route.md new file mode 100644 index 000000000..173c09a37 --- /dev/null +++ b/.changeset/fix-cache-interceptor-index-route.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/aws": patch +--- + +Fix cache interception for index routes by normalizing the route path to `/` while reading the generated cache asset from `/index`. diff --git a/packages/open-next/src/core/routing/cacheInterceptor.ts b/packages/open-next/src/core/routing/cacheInterceptor.ts index 7e1fd217e..eb93b17c2 100644 --- a/packages/open-next/src/core/routing/cacheInterceptor.ts +++ b/packages/open-next/src/core/routing/cacheInterceptor.ts @@ -265,23 +265,23 @@ export async function cacheInterceptor( localizedPath = localizedPath.replace(/\/$/, ""); // Then we decode the path params - localizedPath = decodePathParams(localizedPath); + localizedPath = decodePathParams(localizedPath) || "/"; + + // The route is keyed as `/` in the prerender manifest, but the generated + // cache asset for the app index route is uploaded as `/index`. + const cacheKey = localizedPath === "/" ? "/index" : localizedPath; debug("Checking cache for", localizedPath, PrerenderManifest); const isISR = - Object.keys(PrerenderManifest?.routes ?? {}).includes( - localizedPath ?? "/", - ) || + Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath) || Object.values(PrerenderManifest?.dynamicRoutes ?? {}).some((dr) => new RegExp(dr.routeRegex).test(localizedPath), ); debug("isISR", isISR); if (isISR) { try { - const cachedData = await globalThis.incrementalCache.get( - localizedPath ?? "/index", - ); + const cachedData = await globalThis.incrementalCache.get(cacheKey); debug("cached data in interceptor", cachedData); if (!cachedData?.value) { @@ -295,7 +295,7 @@ export async function cacheInterceptor( ) { const _hasBeenRevalidated = cachedData.shouldBypassTagCache ? false - : await hasBeenRevalidated(localizedPath, tags, cachedData); + : await hasBeenRevalidated(cacheKey, tags, cachedData); if (_hasBeenRevalidated) { return event; @@ -305,11 +305,7 @@ export async function cacheInterceptor( // Check if the cache entry is stale (valid but needs background revalidation) const _isStale = cachedData.shouldBypassTagCache ? false - : await isStale( - localizedPath, - tags, - cachedData.lastModified ?? Date.now(), - ); + : await isStale(cacheKey, tags, cachedData.lastModified ?? Date.now()); const host = event.headers.host; switch (cachedData?.value?.type) { diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index b464f4be5..b87461299 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -11,6 +11,11 @@ vi.mock("@opennextjs/aws/adapters/config/index.js", () => ({ NextConfig: {}, PrerenderManifest: { routes: { + "/": { + initialRevalidateSeconds: 120, + srcRoute: "/", + dataRoute: "/index.rsc", + }, "/albums": { initialRevalidateSeconds: false, srcRoute: "/albums", @@ -144,6 +149,15 @@ describe("cacheInterceptor", () => { const body = await fromReadableStream(result.body); expect(body).toEqual("Hello, world!"); + expect(incrementalCache.get).toHaveBeenCalledWith("/albums"); + expect(tagCache.getLastModified).toHaveBeenCalledWith( + "/albums", + expect.any(Number), + ); + expect(tagCache.isStale).toHaveBeenCalledWith( + "/albums", + expect.any(Number), + ); expect(result).toEqual( expect.objectContaining({ type: "core", @@ -159,6 +173,70 @@ describe("cacheInterceptor", () => { ); }); + it("should retrieve index app router content from the index cache key", async () => { + const event = createEvent({ + url: "/", + }); + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Index page", + }, + lastModified: new Date("2024-01-01T23:59:30Z").getTime(), + }); + + const result = await cacheInterceptor(event); + + const body = await fromReadableStream(result.body); + expect(body).toEqual("Index page"); + expect(incrementalCache.get).toHaveBeenCalledWith("/index"); + expect(tagCache.getLastModified).toHaveBeenCalledWith( + "/index", + expect.any(Number), + ); + expect(tagCache.isStale).toHaveBeenCalledWith("/index", expect.any(Number)); + expect(result).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ + "cache-control": "s-maxage=90, stale-while-revalidate=2592000", + "x-opennext-cache": "HIT", + }), + }), + ); + }); + + it("should revalidate stale index content using the route path", async () => { + const event = createEvent({ + url: "/", + }); + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Index page", + }, + lastModified: new Date("2024-01-01T23:57:00Z").getTime(), + }); + + const result = await cacheInterceptor(event); + + expect(incrementalCache.get).toHaveBeenCalledWith("/index"); + expect(queue.send).toHaveBeenCalledWith( + expect.objectContaining({ + MessageBody: expect.objectContaining({ + url: "/", + }), + }), + ); + expect(result).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ + "cache-control": "s-maxage=1, stale-while-revalidate=2592000", + "x-opennext-cache": "STALE", + }), + }), + ); + }); + it("should take no action when tagCache lasModified is -1 for app type", async () => { const event = createEvent({ url: "/albums",