Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Invalidate prefetch cache when a tag or path has been revalidated on the server #50848

Merged
merged 8 commits into from Jun 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -30,6 +30,10 @@ type FetchServerActionResult = {
redirectLocation: URL | undefined
actionResult?: ActionResult
actionFlightData?: FlightData | undefined | null
revalidatedParts: {
tag: boolean
paths: string[]
}
}

async function fetchServerAction(
Expand Down Expand Up @@ -60,6 +64,21 @@ async function fetchServerAction(
})

const location = res.headers.get('x-action-redirect')
let revalidatedParts: FetchServerActionResult['revalidatedParts']
try {
const revalidatedHeader = JSON.parse(
res.headers.get('x-action-revalidated') || '[0,[]]'
)
revalidatedParts = {
tag: !!revalidatedHeader[0],
paths: revalidatedHeader[1] || [],
}
} catch (e) {
revalidatedParts = {
tag: false,
paths: [],
}
}

const redirectLocation = location
? new URL(addBasePath(location), window.location.origin)
Expand All @@ -77,6 +96,7 @@ async function fetchServerAction(
return {
actionFlightData: result as FlightData,
redirectLocation,
revalidatedParts,
}
// otherwise it's a tuple of [actionResult, actionFlightData]
} else {
Expand All @@ -86,11 +106,13 @@ async function fetchServerAction(
actionResult,
actionFlightData,
redirectLocation,
revalidatedParts,
}
}
}
return {
redirectLocation,
revalidatedParts,
}
}

Expand All @@ -116,10 +138,27 @@ export function serverActionReducer(
}
try {
// suspends until the server action is resolved.
const { actionResult, actionFlightData, redirectLocation } =
readRecordValue(
action.mutable.inFlightServerAction!
) as Awaited<FetchServerActionResult>
const {
actionResult,
actionFlightData,
redirectLocation,
revalidatedParts,
} = readRecordValue(
action.mutable.inFlightServerAction!
) as Awaited<FetchServerActionResult>

// Invalidate the cache for the revalidated parts. This has to be done before the
// cache is updated with the action's flight data again.
if (revalidatedParts.tag) {
// Invalidate everything if the tag is set.
state.prefetchCache.clear()
} else if (revalidatedParts.paths.length > 0) {
// Invalidate all subtrees that are below the revalidated paths, and invalidate
// all the prefetch cache.
// TODO-APP: Currently the prefetch cache doesn't have subtree information,
// so we need to invalidate the entire cache if a path was revalidated.
state.prefetchCache.clear()
}

if (redirectLocation) {
// the redirection might have a flight data associated with it, so we'll populate the cache with it
Expand Down
Expand Up @@ -43,6 +43,7 @@ export interface ServerActionMutable {
serverActionApplied?: boolean
previousTree?: FlightRouterState
previousUrl?: string
prefetchCache?: AppRouterState['prefetchCache']
}

/**
Expand Down
31 changes: 28 additions & 3 deletions packages/next/src/server/app-render/action-handler.ts
Expand Up @@ -127,6 +127,30 @@ function fetchIPv4v6(
})
}

async function addRevalidationHeader(
res: ServerResponse,
staticGenerationStore: StaticGenerationStore
) {
await Promise.all(staticGenerationStore.pendingRevalidates || [])

// If a tag was revalidated, the client router needs to invalidate all the
// client router cache as they may be stale. And if a path was revalidated, the
// client needs to invalidate all subtrees below that path.

// To keep the header size small, we use a tuple of [isTagRevalidated ? 1 : 0, [paths]]
// instead of a JSON object.

// TODO-APP: Currently the prefetch cache doesn't have subtree information,
// so we need to invalidate the entire cache if a path was revalidated.
// TODO-APP: Currently paths are treated as tags, so the second element of the tuple
// is always empty.

res.setHeader(
'x-action-revalidated',
JSON.stringify([staticGenerationStore.revalidatedTags?.length ? 1 : 0, []])
)
}

async function createRedirectRenderResult(
req: IncomingMessage,
res: ServerResponse,
Expand Down Expand Up @@ -350,7 +374,7 @@ export async function handleAction({

// For form actions, we need to continue rendering the page.
if (isFetchAction) {
await Promise.all(staticGenerationStore.pendingRevalidates || [])
await addRevalidationHeader(res, staticGenerationStore)

actionResult = await generateFlight({
actionResult: Promise.resolve(returnVal),
Expand All @@ -367,7 +391,7 @@ export async function handleAction({

// if it's a fetch action, we don't want to mess with the status code
// and we'll handle it on the client router
await Promise.all(staticGenerationStore.pendingRevalidates || [])
await addRevalidationHeader(res, staticGenerationStore)

if (isFetchAction) {
return createRedirectRenderResult(
Expand All @@ -394,7 +418,8 @@ export async function handleAction({
} else if (isNotFoundError(err)) {
res.statusCode = 404

await Promise.all(staticGenerationStore.pendingRevalidates || [])
await addRevalidationHeader(res, staticGenerationStore)

if (isFetchAction) {
const promise = Promise.reject(err)
try {
Expand Down
55 changes: 55 additions & 0 deletions test/e2e/app-dir/actions/app-action.test.ts
Expand Up @@ -502,6 +502,61 @@ createNextDescribe(
return newRandomNumber !== randomNumber ? 'success' : 'failure'
}, 'success')
})

it.each(['tag', 'path'])(
'should invalidate client cache when %s is revalidate',
async (type) => {
const browser = await next.browser('/revalidate')
await browser.refresh()

const thankYouNext = await browser
.elementByCss('#thankyounext')
.text()

await browser.elementByCss('#another').click()
await check(async () => {
return browser.elementByCss('#title').text()
}, 'another route')

const newThankYouNext = await browser
.elementByCss('#thankyounext')
.text()

// Should be the same number
expect(thankYouNext).toEqual(newThankYouNext)

await browser.elementByCss('#back').click()

if (type === 'tag') {
await browser.elementByCss('#revalidate-thankyounext').click()
} else {
await browser.elementByCss('#revalidate-path').click()
}

// Should be different
let revalidatedThankYouNext
await check(async () => {
revalidatedThankYouNext = await browser
.elementByCss('#thankyounext')
.text()
return thankYouNext !== revalidatedThankYouNext
? 'success'
: 'failure'
}, 'success')

await browser.elementByCss('#another').click()

// The other page should be revalidated too
await check(async () => {
const newThankYouNext = await browser
.elementByCss('#thankyounext')
.text()
return revalidatedThankYouNext === newThankYouNext
? 'success'
: 'failure'
}, 'success')
}
)
})
}
)
35 changes: 35 additions & 0 deletions test/e2e/app-dir/actions/app/revalidate-2/page.js
@@ -0,0 +1,35 @@
import { revalidateTag } from 'next/cache'
import Link from 'next/link'

export default async function Page() {
const data = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random?page',
{
next: { revalidate: 3600, tags: ['thankyounext'] },
}
).then((res) => res.text())

return (
<>
<h1 id="title">another route</h1>
<Link href="/revalidate" id="back">
Back
</Link>
<p>
{' '}
revalidate (tags: thankyounext): <span id="thankyounext">{data}</span>
</p>
<form>
<button
id="revalidate-tag"
formAction={async () => {
'use server'
revalidateTag('thankyounext')
}}
>
revalidate thankyounext
</button>
</form>
</>
)
}
14 changes: 11 additions & 3 deletions test/e2e/app-dir/actions/app/revalidate/page.js
Expand Up @@ -4,21 +4,22 @@ import {
revalidateTag,
} from 'next/cache'
import { redirect } from 'next/navigation'
import Link from 'next/link'

import { cookies } from 'next/headers'

export default async function Page() {
const data = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random?page',
{
next: { revalidate: 360, tags: ['thankyounext'] },
next: { revalidate: 3600, tags: ['thankyounext'] },
}
).then((res) => res.text())

const data2 = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random?a=b',
{
next: { revalidate: 360, tags: ['thankyounext', 'justputit'] },
next: { revalidate: 3600, tags: ['thankyounext', 'justputit'] },
}
).then((res) => res.text())

Expand All @@ -45,7 +46,14 @@ export default async function Page() {
<p>/revalidate</p>
<p>
{' '}
revalidate (tags: thankyounext): <span id="thankyounext">{data}</span>
revalidate (tags: thankyounext): <span id="thankyounext">
{data}
</span>{' '}
<span>
<Link href="/revalidate-2" id="another">
/revalidate/another-route
</Link>
</span>
</p>
<p>
revalidate (tags: thankyounext, justputit):{' '}
Expand Down