From 0ffea650b9a031e4fc5458c0df8100d9bb25f3cc Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Mon, 30 Sep 2024 16:56:32 -0700 Subject: [PATCH] Fix `revalidateTag()` behaviour when invoked in server components (#70446) (#70642) This backports #70446 to 14-2-1 Fixes #70403 ### What? When `revalidateTag()` is called directly in server components, the cache is not purged for the corresponding tags. Reproduction steps available in #70403 ### How? Check [app-render.tsx](https://github.com/vercel/next.js/compare/canary...abhi12299:fix-revalidatetag-rsc?expand=1#diff-a3e2e024db1faa1b501e0dd6040eaaf0d931cb9878ae0fb0f4c3658daa982768) This issue was introduced in #65296 in this file: [revalidate.ts](https://github.com/vercel/next.js/pull/65296/files#diff-7f0cb5bb30d44b9153d724e31c25859b9aab6cc258b35563a1d9464cd0688283). The lines removed from the file resulted in the revalidation checks to be skipped when there is an RSC request. Also fixed checks on `pendingRevalidates` to also check for `revalidatedTags`. Co-authored-by: Abhishek Mehandiratta <36722596+abhi12299@users.noreply.github.com> --- .../next/src/server/app-render/app-render.tsx | 23 +++++++++- .../server/app-render/flight-render-result.ts | 13 ++++-- .../app/RevalidateViaForm.tsx | 17 ++++++++ .../app/actions/revalidate.ts | 11 +++++ .../app-dir/revalidatetag-rsc/app/layout.tsx | 9 ++++ .../app-dir/revalidatetag-rsc/app/page.tsx | 24 +++++++++++ .../app/revalidate_via_page/page.tsx | 24 +++++++++++ .../app-dir/revalidatetag-rsc/next.config.js | 6 +++ .../revalidatetag-rsc.test.ts | 42 +++++++++++++++++++ 9 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 test/e2e/app-dir/revalidatetag-rsc/app/RevalidateViaForm.tsx create mode 100644 test/e2e/app-dir/revalidatetag-rsc/app/actions/revalidate.ts create mode 100644 test/e2e/app-dir/revalidatetag-rsc/app/layout.tsx create mode 100644 test/e2e/app-dir/revalidatetag-rsc/app/page.tsx create mode 100644 test/e2e/app-dir/revalidatetag-rsc/app/revalidate_via_page/page.tsx create mode 100644 test/e2e/app-dir/revalidatetag-rsc/next.config.js create mode 100644 test/e2e/app-dir/revalidatetag-rsc/revalidatetag-rsc.test.ts diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 3ecfc5f8dbf55..f72112b8ecaf5 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -348,7 +348,23 @@ async function generateFlight( } ) - return new FlightRenderResult(flightReadableStream) + const resultOptions: RenderResultOptions = { + metadata: {}, + } + + if ( + ctx.staticGenerationStore.pendingRevalidates || + ctx.staticGenerationStore.revalidatedTags + ) { + resultOptions.waitUntil = Promise.all([ + ctx.staticGenerationStore.incrementalCache?.revalidateTag( + ctx.staticGenerationStore.revalidatedTags || [] + ), + ...Object.values(ctx.staticGenerationStore.pendingRevalidates || {}), + ]) + } + + return new FlightRenderResult(flightReadableStream, resultOptions) } type RenderToStreamResult = { @@ -1349,7 +1365,10 @@ async function renderToHTMLOrFlightImpl( }) // If we have pending revalidates, wait until they are all resolved. - if (staticGenerationStore.pendingRevalidates) { + if ( + staticGenerationStore.pendingRevalidates || + staticGenerationStore.revalidatedTags + ) { options.waitUntil = Promise.all([ staticGenerationStore.incrementalCache?.revalidateTag( staticGenerationStore.revalidatedTags || [] diff --git a/packages/next/src/server/app-render/flight-render-result.ts b/packages/next/src/server/app-render/flight-render-result.ts index f14dfbc97b94b..7684f4154e4f7 100644 --- a/packages/next/src/server/app-render/flight-render-result.ts +++ b/packages/next/src/server/app-render/flight-render-result.ts @@ -1,11 +1,18 @@ import { RSC_CONTENT_TYPE_HEADER } from '../../client/components/app-router-headers' -import RenderResult from '../render-result' +import RenderResult, { type RenderResultOptions } from '../render-result' /** * Flight Response is always set to RSC_CONTENT_TYPE_HEADER to ensure it does not get interpreted as HTML. */ export class FlightRenderResult extends RenderResult { - constructor(response: string | ReadableStream) { - super(response, { contentType: RSC_CONTENT_TYPE_HEADER, metadata: {} }) + constructor( + response: string | ReadableStream, + options?: RenderResultOptions + ) { + super(response, { + contentType: RSC_CONTENT_TYPE_HEADER, + waitUntil: options?.waitUntil, + metadata: options?.metadata ?? {}, + }) } } diff --git a/test/e2e/app-dir/revalidatetag-rsc/app/RevalidateViaForm.tsx b/test/e2e/app-dir/revalidatetag-rsc/app/RevalidateViaForm.tsx new file mode 100644 index 0000000000000..762d46859ca11 --- /dev/null +++ b/test/e2e/app-dir/revalidatetag-rsc/app/RevalidateViaForm.tsx @@ -0,0 +1,17 @@ +'use client' + +import { revalidate } from './actions/revalidate' + +export default function RevalidateViaForm({ tag }: { tag: string }) { + const handleRevalidate = async () => { + await revalidate(tag) + } + + return ( +
+ +
+ ) +} diff --git a/test/e2e/app-dir/revalidatetag-rsc/app/actions/revalidate.ts b/test/e2e/app-dir/revalidatetag-rsc/app/actions/revalidate.ts new file mode 100644 index 0000000000000..2fba1ff18f207 --- /dev/null +++ b/test/e2e/app-dir/revalidatetag-rsc/app/actions/revalidate.ts @@ -0,0 +1,11 @@ +'use server' + +import { revalidateTag } from 'next/cache' + +export const revalidate = async ( + tag: string +): Promise<{ revalidated: boolean }> => { + revalidateTag(tag) + + return { revalidated: true } +} diff --git a/test/e2e/app-dir/revalidatetag-rsc/app/layout.tsx b/test/e2e/app-dir/revalidatetag-rsc/app/layout.tsx new file mode 100644 index 0000000000000..716a8db36f52c --- /dev/null +++ b/test/e2e/app-dir/revalidatetag-rsc/app/layout.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/revalidatetag-rsc/app/page.tsx b/test/e2e/app-dir/revalidatetag-rsc/app/page.tsx new file mode 100644 index 0000000000000..e4ced2724de32 --- /dev/null +++ b/test/e2e/app-dir/revalidatetag-rsc/app/page.tsx @@ -0,0 +1,24 @@ +import RevalidateViaForm from './RevalidateViaForm' +import Link from 'next/link' + +export default async function Page() { + const data = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random', + { + next: { + tags: ['data'], + revalidate: false, + }, + } + ).then((res) => res.text()) + + return ( +
+ {data} + + + Revalidate via page + +
+ ) +} diff --git a/test/e2e/app-dir/revalidatetag-rsc/app/revalidate_via_page/page.tsx b/test/e2e/app-dir/revalidatetag-rsc/app/revalidate_via_page/page.tsx new file mode 100644 index 0000000000000..319a31582f655 --- /dev/null +++ b/test/e2e/app-dir/revalidatetag-rsc/app/revalidate_via_page/page.tsx @@ -0,0 +1,24 @@ +'use server' + +import Link from 'next/link' +import { revalidateTag } from 'next/cache' + +const RevalidateViaPage = async ({ + searchParams, +}: { + searchParams: Promise<{ tag: string }> +}) => { + const { tag } = await searchParams + revalidateTag(tag) + + return ( +
+
Tag [{tag}] has been revalidated
+ + To Home + +
+ ) +} + +export default RevalidateViaPage diff --git a/test/e2e/app-dir/revalidatetag-rsc/next.config.js b/test/e2e/app-dir/revalidatetag-rsc/next.config.js new file mode 100644 index 0000000000000..807126e4cf0bf --- /dev/null +++ b/test/e2e/app-dir/revalidatetag-rsc/next.config.js @@ -0,0 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/revalidatetag-rsc/revalidatetag-rsc.test.ts b/test/e2e/app-dir/revalidatetag-rsc/revalidatetag-rsc.test.ts new file mode 100644 index 0000000000000..f37f1d5c41eb5 --- /dev/null +++ b/test/e2e/app-dir/revalidatetag-rsc/revalidatetag-rsc.test.ts @@ -0,0 +1,42 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +describe('revalidateTag-rsc', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('should revalidate fetch cache if revalidateTag invoked via server action', async () => { + const browser = await next.browser('/') + const randomNumber = await browser.elementById('data').text() + await browser.refresh() + const randomNumber2 = await browser.elementById('data').text() + expect(randomNumber).toEqual(randomNumber2) + + await browser.elementByCss('#submit-form').click() + + await retry(async () => { + const randomNumber3 = await browser.elementById('data').text() + expect(randomNumber3).not.toEqual(randomNumber) + }) + }) + + it('should revalidate fetch cache if revalidateTag invoked via server component', async () => { + const browser = await next.browser('/') + const randomNumber = await browser.elementById('data').text() + await browser.refresh() + const randomNumber2 = await browser.elementById('data').text() + expect(randomNumber).toEqual(randomNumber2) + + await browser.elementByCss('#revalidate-via-page').click() + // need to refresh to evict client router cache + await browser.waitForElementByCss('#home') + await browser.refresh() + + await browser.elementByCss('#home').click() + + await browser.waitForElementByCss('#data') + const randomNumber3 = await browser.elementById('data').text() + expect(randomNumber3).not.toEqual(randomNumber) + }) +})