fix(next): clear bfcache on forward/back (#13913)

Fixes #12914.

Using the forward/back browser navigation shows stale data from the
previous visit.

For example:
1. Visit the list view, imagine a document with a title of "123"
2. Navigate to that document, update the title to "456"
3. Press the "back" button in the browser
4. Page incorrectly shows "123"
5. Press the "forward" button in the browser
6. Page incorrectly shows "123"

This is because Next.js caches those pages in memory in their
[Client-side Router
Cache](https://nextjs.org/docs/app/guides/caching#client-side-router-cache).
This enables instant loads during forward and back navigation by
restoring the previously cached response instead of making a new
request—which also happens to be our exact problem. This bfcache-like
behavior is not able to be opted out of, even if the page requires
authentication, etc.

The [hopefully temporary] fix is to force the router to make a new
request on forward/back navigation. We can do this by listening to the
popstate event and calling `router.refresh()`. This does create a flash
of stale content, however, because the refresh takes place _after_ the
cache was restored. While not wonderful, this is targeted to
specifically the forward/back events, and it's technically not
duplicative as the restored cache never made a request in the first
place.

Without native support, I'm not sure how else we'd achieve this, as
there's not way to invalidate the list view from a deeply nested
document drawer, for example.

Before:


https://github.com/user-attachments/assets/751b33b2-1926-47d2-acba-b1d47df06a6d

After:


https://github.com/user-attachments/assets/fe71938a-5c64-4756-a2c7-45dced4fcaaa

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211454879207837
This commit is contained in:
Jacob Fletcher
2025-09-24 19:08:49 -04:00
committed by GitHub
parent 7bbd07c4a5
commit f868ed981b
2 changed files with 65 additions and 3 deletions

View File

@@ -1,7 +1,9 @@
'use client'
import { usePathname, useRouter } from 'next/navigation.js'
import React, { createContext, use, useCallback, useEffect } from 'react'
import React, { createContext, use, useCallback, useEffect, useRef } from 'react'
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
export type RouteCacheContext = {
cachingEnabled: boolean
@@ -20,15 +22,41 @@ export const RouteCache: React.FC<{ cachingEnabled?: boolean; children: React.Re
const pathname = usePathname()
const router = useRouter()
/**
* Next.js caches pages in memory in their {@link https://nextjs.org/docs/app/guides/caching#client-side-router-cache Client-side Router Cache},
* causing forward/back browser navigation to show stale data from a previous visit.
* The problem is this bfcache-like behavior has no opt-out, even if the page is dynamic, requires authentication, etc.
* The workaround is to force a refresh when navigating via the browser controls.
* This should be removed if/when Next.js supports this natively.
*/
const clearAfterPathnameChange = useRef(false)
const clearRouteCache = useCallback(() => {
router.refresh()
}, [router])
useEffect(() => {
if (cachingEnabled) {
const handlePopState = () => {
clearAfterPathnameChange.current = true
}
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
}, [router])
const handlePathnameChange = useEffectEvent((pathname: string) => {
if (cachingEnabled || clearAfterPathnameChange.current) {
clearAfterPathnameChange.current = false
clearRouteCache()
}
}, [pathname, clearRouteCache, cachingEnabled])
})
useEffect(() => {
handlePathnameChange(pathname)
}, [pathname])
return <Context value={{ cachingEnabled, clearRouteCache }}>{children}</Context>
}

View File

@@ -53,6 +53,7 @@ import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import { openNav } from 'helpers/e2e/toggleNav.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../../../helpers/sdk/index.js'
@@ -377,6 +378,39 @@ describe('General', () => {
// Should redirect to dashboard
await expect.poll(() => page.url()).toBe(`${serverURL}/admin`)
})
/**
* This test is skipped because `page.goBack()` and `page.goForward()` do not trigger navigation in the Next.js app.
* I also tried rendering buttons that call `router.back()` and click those instead, but that also does not work.
*/
test.skip("should clear the router's bfcache when navigating via the forward/back browser controls", async () => {
const { id } = await createPost({
title: 'Post to test bfcache',
})
// check for it in the list view first
await page.goto(postsUrl.list)
const cell = page.locator('.table td').filter({ hasText: 'Post to test bfcache' })
await page.locator('.table a').filter({ hasText: id }).click()
await page.waitForURL(`${postsUrl.edit(id)}`)
const titleField = page.locator('#field-title')
await expect(titleField).toHaveValue('Post to test bfcache')
// change the title to something else
await titleField.fill('Post to test bfcache - updated')
await saveDocAndAssert(page)
// now use the browser controls to go back to the list
await page.goBack()
await page.waitForURL(postsUrl.list)
await expect(cell).toBeVisible()
// and then forward to the edit page again
await page.goForward()
await page.waitForURL(`${postsUrl.edit(id)}`)
await expect(titleField).toHaveValue('Post to test bfcache - updated')
})
})
describe('navigation', () => {