Skip to content

Commit

Permalink
Invalidate prefetch cache when a tag or path has been revalidated on …
Browse files Browse the repository at this point in the history
…the server (#50848)

This makes sure that if `revalidateTag` is called in a Server Action, the client router cache and prefetch cache are invalidated correctly so following navigations won't reuse the cache that might hold stale data.

Similar case for `revalidatePath`. I left a TODO where we can't just invalidate the subtree under the revalidate paths because of current implementation limitations. To ensure correctness, we just do the same as `revalidateTag`.
  • Loading branch information
shuding committed Jun 8, 2023
1 parent 2010928 commit b7d438c
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 10 deletions.
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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

0 comments on commit b7d438c

Please sign in to comment.