feat(next): prevent admin panel errors when cacheComponents is enabled (#16020)
Fixes https://github.com/payloadcms/payload/issues/8897, addresses https://github.com/payloadcms/payload/discussions/14460 Adds initial support for Next.js `cacheComponents` so users who enable it for their frontend don't get errors from the Payload admin panel. This PR addresses the obvious breakage but does not guarantee full compatibility - see the "Known Limitations" section below. When `cacheComponents` is enabled in `next.config`, Next.js throws "Data that blocks navigation was accessed outside of `<Suspense>`" errors because the admin layout reads cookies, headers, and does auth queries at the top level. This prevents users from enabling `cacheComponents` at all if Payload is in the same Next.js app. The fix has two parts. First, `withPayload` now detects `cacheComponents` in the Next.js config and sets a `PAYLOAD_CACHE_COMPONENTS_ENABLED` env var. Second, `RootLayout` reads that env var and conditionally wraps its content in `<Suspense fallback={null}>` above the `<html>` tag, which suppresses the errors. When `cacheComponents` is not enabled, the Suspense is not used at all and behavior is identical to before. ## Known Limitations These are all caused by Next.js's `cacheComponents` and likely cannot be fixed from our side. ### Page flash on hard refresh When `cacheComponents` is enabled, hard refresh shows a brief gray flash before the admin panel appears. Without `cacheComponents` there is no flash. There is no per-route opt-out for this behavior. Related issue: https://github.com/vercel/next.js/issues/86739 ### HTTP status codes (404 returns 200) With `cacheComponents`, `notFound()` returns HTTP 200 instead of 404. This happens because the Suspense boundary above `<html>` causes Next.js to commit response headers (with status 200) before `notFound()` runs inside the suspended content. The not-found UI still renders correctly - only the HTTP status code is wrong. This is a [documented Next.js streaming limitation](https://nextjs.org/docs/app/api-reference/file-conventions/loading#status-codes). ### DOM accumulation breaks Playwright selectors When `cacheComponents` is enabled, Next.js wraps route segments in React's `<Activity>` component, keeping up to 3 previously visited pages in the DOM with `display: none !important` instead of unmounting them. This means Playwright selectors like `page.locator('#field-title')` resolve to multiple elements (the visible one and hidden copies from cached pages), causing strict mode violations. This is a [known issue](https://github.com/vercel/next.js/issues/86577) affecting all Next.js apps using `cacheComponents` with Playwright. Because of this, we cannot reliably run our e2e test suite with `cacheComponents` enabled. Adapting the test suite would require rewriting a large number of selectors across hundreds of tests - most of our e2e tests use `page.locator()` with ID selectors, which would all break when Activity duplicates the DOM. Until the Next.js team provides a per-route opt-out for Activity (which they are [actively exploring](https://github.com/vercel/next.js/issues/86577#issuecomment-3801284197)), we cannot _guarantee_ full admin panel compatibility beyond the initial error suppression this PR provides.
This commit is contained in:
@@ -293,13 +293,14 @@ jobs:
|
||||
tests-e2e:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [e2e-matrix, e2e-prep]
|
||||
name: E2E - ${{ matrix.suite }}${{ matrix.total-shards > 1 && format(' ({0}/{1})', matrix.shard, matrix.total-shards) || '' }}
|
||||
name: E2E - ${{ matrix.suite }}${{ matrix.total-shards > 1 && format(' ({0}/{1})', matrix.shard, matrix.total-shards) || '' }}${{ matrix.cacheComponents && ' [cacheComponents]' || '' }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.e2e-matrix.outputs.matrix) }}
|
||||
env:
|
||||
SUITE_NAME: ${{ matrix.suite }}
|
||||
PAYLOAD_CACHE_COMPONENTS: ${{ matrix.cacheComponents && 'true' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -396,7 +397,7 @@ jobs:
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results-${{ matrix.suite }}_${{ matrix.shard }}
|
||||
name: test-results-${{ matrix.suite }}_${{ matrix.shard }}${{ matrix.cacheComponents && '_cc' || '' }}
|
||||
path: test/test-results/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface TestConfig {
|
||||
shards: number
|
||||
/** Whether tests can run in parallel (default: false) */
|
||||
parallel?: boolean
|
||||
/** Whether to enable cacheComponents for this test run */
|
||||
cacheComponents?: boolean
|
||||
}
|
||||
|
||||
interface MatrixEntry {
|
||||
@@ -19,6 +21,7 @@ interface MatrixEntry {
|
||||
shard: number
|
||||
'total-shards': number
|
||||
parallel: boolean
|
||||
cacheComponents: boolean
|
||||
}
|
||||
|
||||
interface Matrix {
|
||||
@@ -28,13 +31,14 @@ interface Matrix {
|
||||
function generateMatrix(testConfigs: TestConfig[]): Matrix {
|
||||
const include: MatrixEntry[] = []
|
||||
|
||||
for (const { file, shards, parallel = false } of testConfigs) {
|
||||
for (const { file, shards, parallel = false, cacheComponents = false } of testConfigs) {
|
||||
for (let shard = 1; shard <= shards; shard++) {
|
||||
include.push({
|
||||
suite: file,
|
||||
shard,
|
||||
'total-shards': shards,
|
||||
parallel,
|
||||
cacheComponents,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ Payload requires the following software:
|
||||
sure you're using one of the supported version ranges listed above.
|
||||
</Banner>
|
||||
|
||||
<Banner type="info">
|
||||
**Cache Components:** While Next.js `cacheComponents` can be enabled alongside
|
||||
Payload without causing errors in the admin panel, full compatibility is not
|
||||
guaranteed. See this [GitHub pull
|
||||
request](https://github.com/payloadcms/payload/pull/16020) for the latest
|
||||
status.
|
||||
</Banner>
|
||||
|
||||
## Quickstart with create-payload-app
|
||||
|
||||
To quickly scaffold a new Payload app in the fastest way possible, you can use [create-payload-app](https://npmjs.com/package/create-payload-app). To do so, run the following command:
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@ const withBundleAnalyzer = bundleAnalyzer({
|
||||
const config = withBundleAnalyzer(
|
||||
withPayload(
|
||||
{
|
||||
cacheComponents: process.env.PAYLOAD_CACHE_COMPONENTS === 'true',
|
||||
basePath: process.env?.NEXT_BASE_PATH || undefined,
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
@@ -45,7 +46,7 @@ const config = withBundleAnalyzer(
|
||||
hostname: 'localhost',
|
||||
},
|
||||
],
|
||||
qualities: [5, 50, 75, 100]
|
||||
qualities: [5, 50, 75, 100],
|
||||
},
|
||||
webpack: (webpackConfig) => {
|
||||
webpackConfig.resolve.extensionAlias = {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ProgressBar, RootProvider } from '@payloadcms/ui'
|
||||
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
|
||||
import { cookies as nextCookies } from 'next/headers.js'
|
||||
import { applyLocaleFiltering } from 'payload/shared'
|
||||
import React from 'react'
|
||||
import React, { Suspense } from 'react'
|
||||
|
||||
import { getNavPrefs } from '../../elements/Nav/getNavPrefs.js'
|
||||
import { getRequestTheme } from '../../utilities/getRequestTheme.js'
|
||||
@@ -21,21 +21,48 @@ export const metadata = {
|
||||
title: 'Next.js',
|
||||
}
|
||||
|
||||
export const RootLayout = async ({
|
||||
children,
|
||||
config: configPromise,
|
||||
htmlProps = {},
|
||||
importMap,
|
||||
serverFunction,
|
||||
}: {
|
||||
type RootLayoutProps = {
|
||||
readonly children: React.ReactNode
|
||||
readonly config: Promise<SanitizedConfig>
|
||||
readonly htmlProps?: React.HtmlHTMLAttributes<HTMLHtmlElement>
|
||||
readonly importMap: ImportMap
|
||||
readonly serverFunction: ServerFunctionClient
|
||||
}) => {
|
||||
}
|
||||
|
||||
export const RootLayout = ({
|
||||
children,
|
||||
config: configPromise,
|
||||
htmlProps,
|
||||
importMap,
|
||||
serverFunction,
|
||||
}: RootLayoutProps) => {
|
||||
checkDependencies()
|
||||
|
||||
const content = (
|
||||
<RootLayoutContent
|
||||
config={configPromise}
|
||||
htmlProps={htmlProps}
|
||||
importMap={importMap}
|
||||
serverFunction={serverFunction}
|
||||
>
|
||||
{children}
|
||||
</RootLayoutContent>
|
||||
)
|
||||
|
||||
if (process.env.PAYLOAD_CACHE_COMPONENTS_ENABLED === 'true') {
|
||||
return <Suspense fallback={null}>{content}</Suspense>
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
const RootLayoutContent = async ({
|
||||
children,
|
||||
config: configPromise,
|
||||
htmlProps = {},
|
||||
importMap,
|
||||
serverFunction,
|
||||
}: RootLayoutProps) => {
|
||||
const {
|
||||
cookies,
|
||||
headers,
|
||||
|
||||
@@ -71,6 +71,14 @@ export const APIViewClient: React.FC = () => {
|
||||
)
|
||||
const [authenticated, setAuthenticated] = React.useState<boolean>(true)
|
||||
const [fullscreen, setFullscreen] = React.useState<boolean>(false)
|
||||
const [origin, setOrigin] = React.useState<string>(serverURL || '')
|
||||
|
||||
// Set the origin to the window.location.origin in useEffect to avoid hydration errors
|
||||
React.useEffect(() => {
|
||||
if (!serverURL) {
|
||||
setOrigin(window.location.origin)
|
||||
}
|
||||
}, [serverURL])
|
||||
|
||||
const trashParam = typeof initialData?.deletedAt === 'string'
|
||||
|
||||
@@ -84,7 +92,7 @@ export const APIViewClient: React.FC = () => {
|
||||
const fetchURL = formatAdminURL({
|
||||
apiRoute,
|
||||
path: `${docEndpoint}?${params}`,
|
||||
serverURL: serverURL || window.location.origin,
|
||||
serverURL: origin,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -34,6 +34,10 @@ export const withPayload = (nextConfig = {}, options = {}) => {
|
||||
env.NEXT_PUBLIC_ENABLE_ROUTER_CACHE_REFRESH = 'true'
|
||||
}
|
||||
|
||||
if (nextConfig.cacheComponents) {
|
||||
env.PAYLOAD_CACHE_COMPONENTS_ENABLED = 'true'
|
||||
}
|
||||
|
||||
const consoleWarn = console.warn
|
||||
|
||||
const sassWarningTexts = [
|
||||
|
||||
@@ -14,6 +14,7 @@ const withBundleAnalyzer = bundleAnalyzer({
|
||||
export default withBundleAnalyzer(
|
||||
withPayload(
|
||||
{
|
||||
cacheComponents: process.env.PAYLOAD_CACHE_COMPONENTS === 'true',
|
||||
devIndicators: {
|
||||
position: 'bottom-right',
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
dotenv.config({ path: path.resolve(dirname, 'test.env') })
|
||||
dotenv.config({ path: path.resolve(dirname, '..', '.env') })
|
||||
|
||||
const CI = process.env.CI === 'true'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user