diff --git a/packages/ui/src/providers/RouteCache/index.tsx b/packages/ui/src/providers/RouteCache/index.tsx index 14619b3ea..36e8bf5ee 100644 --- a/packages/ui/src/providers/RouteCache/index.tsx +++ b/packages/ui/src/providers/RouteCache/index.tsx @@ -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 {children} } diff --git a/test/admin/e2e/general/e2e.spec.ts b/test/admin/e2e/general/e2e.spec.ts index 6710f65c0..f94839c52 100644 --- a/test/admin/e2e/general/e2e.spec.ts +++ b/test/admin/e2e/general/e2e.spec.ts @@ -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', () => {