diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index 482d84550720..ac95be913d42 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -694,14 +694,18 @@ function createPatchedFetcher( // If enabled, we should bail out of static generation. trackDynamicFetch(staticGenerationStore, dynamicUsageReason) - // PPR is not enabled, or React postpone is not available, we - // should set the revalidate to 0. - staticGenerationStore.revalidate = 0 - - const err = new DynamicServerError(dynamicUsageReason) - staticGenerationStore.dynamicUsageErr = err - staticGenerationStore.dynamicUsageDescription = dynamicUsageReason - throw err + // If partial prerendering is not enabled, then we should throw an + // error to indicate that this fetch is dynamic. + if (!staticGenerationStore.prerenderState) { + // PPR is not enabled, or React postpone is not available, we + // should set the revalidate to 0. + staticGenerationStore.revalidate = 0 + + const err = new DynamicServerError(dynamicUsageReason) + staticGenerationStore.dynamicUsageErr = err + staticGenerationStore.dynamicUsageDescription = dynamicUsageReason + throw err + } } const hasNextConfig = 'next' in init @@ -726,10 +730,15 @@ function createPatchedFetcher( // If enabled, we should bail out of static generation. trackDynamicFetch(staticGenerationStore, dynamicUsageReason) - const err = new DynamicServerError(dynamicUsageReason) - staticGenerationStore.dynamicUsageErr = err - staticGenerationStore.dynamicUsageDescription = dynamicUsageReason - throw err + // If partial prerendering is not enabled, then we should throw an + // error to indicate that this fetch is dynamic. + if (!staticGenerationStore.prerenderState) { + const err = new DynamicServerError(dynamicUsageReason) + staticGenerationStore.dynamicUsageErr = err + staticGenerationStore.dynamicUsageDescription = + dynamicUsageReason + throw err + } } if (!staticGenerationStore.forceStatic || next.revalidate !== 0) { diff --git a/test/e2e/app-dir/app-static/app-static.test.ts b/test/e2e/app-dir/app-static/app-static.test.ts index de9a1419475f..2ab2466097fd 100644 --- a/test/e2e/app-dir/app-static/app-static.test.ts +++ b/test/e2e/app-dir/app-static/app-static.test.ts @@ -3213,79 +3213,6 @@ describe('app-dir static/dynamic handling', () => { }) describe('unstable_cache', () => { - if (isNextStart) { - describe('fetch', () => { - let server: http.Server | null = null - afterEach(async () => { - if (!server) return - - await server.close() - server = null - }) - - it('should not cache inner fetch calls', async () => { - let generations: string[] = [] - server = http.createServer(async (key, res) => { - const random = Math.floor(Math.random() * 100).toString() - generations.push(random) - res.end(random) - }) - const port = await findPort() - server.listen(port) - const address = `http://localhost:${port}/` - - const first = await next - .fetch('/unstable-cache/fetch', { - headers: { - 'X-Test-Data-Server': address, - }, - }) - .then((res) => res.json()) - - expect(generations).toHaveLength(1) - expect(first).toEqual( - expect.objectContaining({ - data: generations[0], - }) - ) - - const second = await next - .fetch('/unstable-cache/fetch', { - headers: { - 'X-Test-Data-Server': address, - }, - }) - .then((res) => res.json()) - - expect(generations).toHaveLength(1) - expect(first).toEqual(second) - - // Revalidate the cache for the unstable_cache, but explicitly not - // the inner fetch call. We expect it to not cache either. - await next.fetch('/unstable-cache/fetch?tag=unstable-cache-fetch', { - method: 'DELETE', - }) - - const third = await next - .fetch('/unstable-cache/fetch', { - headers: { - 'X-Test-Data-Server': address, - }, - }) - .then((res) => res.json()) - - expect(generations).toHaveLength(2) - expect(generations[1]).not.toEqual(generations[0]) - expect(third).toEqual( - expect.objectContaining({ - data: generations[1], - }) - ) - expect(third).not.toEqual(first) - }) - }) - } - it('should retrieve the same value on second request', async () => { const res = await next.fetch('/unstable-cache/dynamic') const html = await res.text() diff --git a/test/e2e/app-dir/app-static/app/unstable-cache/fetch/route.ts b/test/e2e/app-dir/app-static/app/unstable-cache/fetch/route.ts deleted file mode 100644 index c499cddac521..000000000000 --- a/test/e2e/app-dir/app-static/app/unstable-cache/fetch/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { revalidateTag, unstable_cache } from 'next/cache' - -export const dynamic = 'force-dynamic' - -const getData = unstable_cache( - async (address: string): Promise => { - const res = await fetch(address, { - cache: 'force-cache', - next: { tags: ['unstable-cache-inner-fetch'] }, - }) - - const data = await res.text() - - return JSON.stringify({ - random: Math.floor(Math.random() * 100).toString(), - data, - }) - }, - undefined, - { - tags: ['unstable-cache-fetch'], - } -) - -export const GET = async (request: Request): Promise => { - const address = request.headers.get('x-test-data-server') - if (!address) { - return new Response('Missing address', { status: 400 }) - } - - const data = await getData(address) - - return new Response(data, { - headers: { - 'Content-Type': 'application/json', - }, - }) -} - -export const DELETE = async (request: Request): Promise => { - const url = new URL(request.url) - const tags = url.searchParams.getAll('tag') - - for (const tag of tags) { - revalidateTag(tag) - } - - return new Response('OK', { status: 200 }) -} diff --git a/test/e2e/app-dir/ppr-unstable-cache/app/layout.jsx b/test/e2e/app-dir/ppr-unstable-cache/app/layout.jsx new file mode 100644 index 000000000000..750eb927b198 --- /dev/null +++ b/test/e2e/app-dir/ppr-unstable-cache/app/layout.jsx @@ -0,0 +1,7 @@ +export default function Layout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/ppr-unstable-cache/app/page.jsx b/test/e2e/app-dir/ppr-unstable-cache/app/page.jsx new file mode 100644 index 000000000000..eacbb219718f --- /dev/null +++ b/test/e2e/app-dir/ppr-unstable-cache/app/page.jsx @@ -0,0 +1,37 @@ +import { unstable_cache } from 'next/cache' + +const getData = unstable_cache( + async () => { + const noStore = await fetch( + process.env.TEST_DATA_SERVER + '?cache=no-store', + { method: 'GET', cache: 'no-store' } + ).then((res) => res.text()) + + const forceCache = await fetch( + process.env.TEST_DATA_SERVER + '?cache=force-cache', + { method: 'GET', cache: 'force-cache' } + ).then((res) => res.text()) + + return JSON.stringify( + { + random: Math.floor(Math.random() * 1000).toString(), + data: { + forceCache, + noStore, + }, + }, + null, + 2 + ) + }, + undefined, + { + tags: ['unstable-cache-fetch'], + } +) + +export default async function Page() { + const data = await getData() + + return
{data}
+} diff --git a/test/e2e/app-dir/ppr-unstable-cache/app/revalidate-tag/route.js b/test/e2e/app-dir/ppr-unstable-cache/app/revalidate-tag/route.js new file mode 100644 index 000000000000..f50aaa8643f3 --- /dev/null +++ b/test/e2e/app-dir/ppr-unstable-cache/app/revalidate-tag/route.js @@ -0,0 +1,6 @@ +import { revalidateTag } from 'next/cache' + +export const POST = async () => { + revalidateTag('unstable-cache-fetch') + return new Response('OK', { status: 200 }) +} diff --git a/test/e2e/app-dir/ppr-unstable-cache/next.config.js b/test/e2e/app-dir/ppr-unstable-cache/next.config.js new file mode 100644 index 000000000000..6013aed78629 --- /dev/null +++ b/test/e2e/app-dir/ppr-unstable-cache/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + ppr: true, + }, +} diff --git a/test/e2e/app-dir/ppr-unstable-cache/ppr-unstable-cache.test.ts b/test/e2e/app-dir/ppr-unstable-cache/ppr-unstable-cache.test.ts new file mode 100644 index 000000000000..f4b5bb01636d --- /dev/null +++ b/test/e2e/app-dir/ppr-unstable-cache/ppr-unstable-cache.test.ts @@ -0,0 +1,103 @@ +import { NextInstance, createNext, isNextDeploy, isNextDev } from 'e2e-utils' +import { findPort } from 'next-test-utils' +import http from 'node:http' + +describe('ppr-unstable-cache', () => { + if (isNextDeploy) { + it.skip('should not run in deploy mode', () => {}) + return + } + + if (isNextDev) { + it.skip('should not run in dev mode', () => {}) + return + } + + let next: NextInstance | null = null + let server: http.Server | null = null + afterEach(async () => { + if (next) { + await next.destroy() + next = null + } + + if (server) { + await server.close() + server = null + } + }) + + it('should not cache inner fetch calls', async () => { + let generations: string[] = [] + server = http.createServer(async (req, res) => { + try { + if (!req.url) throw new Error('No URL') + + const cache = new URL(req.url, 'http://n').searchParams.get('cache') + if (!cache) throw new Error('No cache key') + + const random = Math.floor(Math.random() * 1000).toString() + const data = cache + ':' + random + generations.push(data) + res.end(data) + } catch (err) { + res.statusCode = 500 + res.end(err.message) + } + }) + const port = await findPort() + server.listen(port) + + next = await createNext({ + files: __dirname, + env: { TEST_DATA_SERVER: `http://localhost:${port}/` }, + }) + + expect(generations).toHaveLength(3) + + const first = await next + .render$('/') + .then(($) => JSON.parse($('#data').text())) + + expect(generations).toHaveLength(3) + + expect(first.data.forceCache).toBeOneOf(generations) + expect(first.data.noStore).toBeOneOf(generations) + + // Try a few more times, we should always get the same result. + for (let i = 0; i < 3; i++) { + const again = await next + .render$('/') + .then(($) => JSON.parse($('#data').text())) + + expect(generations).toHaveLength(3) + expect(first).toEqual(again) + } + + // Revalidate the tag associated with the `unstable_cache` call. + const revalidate = await next.fetch('/revalidate-tag', { method: 'POST' }) + expect(revalidate.status).toBe(200) + await revalidate.text() + + const revalidated = await next + .render$('/') + .then(($) => JSON.parse($('#data').text())) + + // Expect that the `cache: no-store` value has been updated, but not + // the `cache: force-cache` value. + expect(generations).toHaveLength(5) + + // We know now that the generations have been updated, so let's try to + // validate the value. We don't need to do this within the retry. + expect(revalidated.random).not.toEqual(first.random) + expect(revalidated.data.forceCache).toBeOneOf(generations.slice(0, 3)) + expect(revalidated.data.noStore).toBeOneOf(generations.slice(3)) + expect(revalidated).not.toEqual(first) + + // Ensure that the `force-cache` value has not been updated, and only called + // once. + expect(generations.filter((g) => g.startsWith('force-cache'))).toHaveLength( + 1 + ) + }) +})