Skip to content

Commit 1ecd7dd

Browse files
authored
feat(next): prevent admin panel errors when cacheComponents is enabled (#16020)
Fixes #8897, addresses #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: vercel/next.js#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](vercel/next.js#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](vercel/next.js#86577 (comment))), we cannot _guarantee_ full admin panel compatibility beyond the initial error suppression this PR provides.
1 parent 1a0f4d0 commit 1ecd7dd

File tree

9 files changed

+69
-14
lines changed

9 files changed

+69
-14
lines changed

.github/workflows/main.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,13 +293,14 @@ jobs:
293293
tests-e2e:
294294
runs-on: ubuntu-24.04
295295
needs: [e2e-matrix, e2e-prep]
296-
name: E2E - ${{ matrix.suite }}${{ matrix.total-shards > 1 && format(' ({0}/{1})', matrix.shard, matrix.total-shards) || '' }}
296+
name: E2E - ${{ matrix.suite }}${{ matrix.total-shards > 1 && format(' ({0}/{1})', matrix.shard, matrix.total-shards) || '' }}${{ matrix.cacheComponents && ' [cacheComponents]' || '' }}
297297
timeout-minutes: 45
298298
strategy:
299299
fail-fast: false
300300
matrix: ${{ fromJson(needs.e2e-matrix.outputs.matrix) }}
301301
env:
302302
SUITE_NAME: ${{ matrix.suite }}
303+
PAYLOAD_CACHE_COMPONENTS: ${{ matrix.cacheComponents && 'true' || '' }}
303304
steps:
304305
- uses: actions/checkout@v5
305306

@@ -396,7 +397,7 @@ jobs:
396397
- uses: actions/upload-artifact@v4
397398
if: always()
398399
with:
399-
name: test-results-${{ matrix.suite }}_${{ matrix.shard }}
400+
name: test-results-${{ matrix.suite }}_${{ matrix.shard }}${{ matrix.cacheComponents && '_cc' || '' }}
400401
path: test/test-results/
401402
if-no-files-found: ignore
402403
retention-days: 1

.github/workflows/utilities/e2e-matrix.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@ export interface TestConfig {
1212
shards: number
1313
/** Whether tests can run in parallel (default: false) */
1414
parallel?: boolean
15+
/** Whether to enable cacheComponents for this test run */
16+
cacheComponents?: boolean
1517
}
1618

1719
interface MatrixEntry {
1820
suite: string
1921
shard: number
2022
'total-shards': number
2123
parallel: boolean
24+
cacheComponents: boolean
2225
}
2326

2427
interface Matrix {
@@ -28,13 +31,14 @@ interface Matrix {
2831
function generateMatrix(testConfigs: TestConfig[]): Matrix {
2932
const include: MatrixEntry[] = []
3033

31-
for (const { file, shards, parallel = false } of testConfigs) {
34+
for (const { file, shards, parallel = false, cacheComponents = false } of testConfigs) {
3235
for (let shard = 1; shard <= shards; shard++) {
3336
include.push({
3437
suite: file,
3538
shard,
3639
'total-shards': shards,
3740
parallel,
41+
cacheComponents,
3842
})
3943
}
4044
}

docs/getting-started/installation.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ Payload requires the following software:
2525
sure you're using one of the supported version ranges listed above.
2626
</Banner>
2727

28+
<Banner type="info">
29+
**Cache Components:** While Next.js `cacheComponents` can be enabled alongside
30+
Payload without causing errors in the admin panel, full compatibility is not
31+
guaranteed. See this [GitHub pull
32+
request](https://github.com/payloadcms/payload/pull/16020) for the latest
33+
status.
34+
</Banner>
35+
2836
## Quickstart with create-payload-app
2937

3038
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:

next.config.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const withBundleAnalyzer = bundleAnalyzer({
1414
const config = withBundleAnalyzer(
1515
withPayload(
1616
{
17+
cacheComponents: process.env.PAYLOAD_CACHE_COMPONENTS === 'true',
1718
basePath: process.env?.NEXT_BASE_PATH || undefined,
1819
typescript: {
1920
ignoreBuildErrors: true,
@@ -45,7 +46,7 @@ const config = withBundleAnalyzer(
4546
hostname: 'localhost',
4647
},
4748
],
48-
qualities: [5, 50, 75, 100]
49+
qualities: [5, 50, 75, 100],
4950
},
5051
webpack: (webpackConfig) => {
5152
webpackConfig.resolve.extensionAlias = {

packages/next/src/layouts/Root/index.tsx

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ProgressBar, RootProvider } from '@payloadcms/ui'
66
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
77
import { cookies as nextCookies } from 'next/headers.js'
88
import { applyLocaleFiltering } from 'payload/shared'
9-
import React from 'react'
9+
import React, { Suspense } from 'react'
1010

1111
import { getNavPrefs } from '../../elements/Nav/getNavPrefs.js'
1212
import { getRequestTheme } from '../../utilities/getRequestTheme.js'
@@ -21,21 +21,48 @@ export const metadata = {
2121
title: 'Next.js',
2222
}
2323

24-
export const RootLayout = async ({
25-
children,
26-
config: configPromise,
27-
htmlProps = {},
28-
importMap,
29-
serverFunction,
30-
}: {
24+
type RootLayoutProps = {
3125
readonly children: React.ReactNode
3226
readonly config: Promise<SanitizedConfig>
3327
readonly htmlProps?: React.HtmlHTMLAttributes<HTMLHtmlElement>
3428
readonly importMap: ImportMap
3529
readonly serverFunction: ServerFunctionClient
36-
}) => {
30+
}
31+
32+
export const RootLayout = ({
33+
children,
34+
config: configPromise,
35+
htmlProps,
36+
importMap,
37+
serverFunction,
38+
}: RootLayoutProps) => {
3739
checkDependencies()
3840

41+
const content = (
42+
<RootLayoutContent
43+
config={configPromise}
44+
htmlProps={htmlProps}
45+
importMap={importMap}
46+
serverFunction={serverFunction}
47+
>
48+
{children}
49+
</RootLayoutContent>
50+
)
51+
52+
if (process.env.PAYLOAD_CACHE_COMPONENTS_ENABLED === 'true') {
53+
return <Suspense fallback={null}>{content}</Suspense>
54+
}
55+
56+
return content
57+
}
58+
59+
const RootLayoutContent = async ({
60+
children,
61+
config: configPromise,
62+
htmlProps = {},
63+
importMap,
64+
serverFunction,
65+
}: RootLayoutProps) => {
3966
const {
4067
cookies,
4168
headers,

packages/next/src/views/API/index.client.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ export const APIViewClient: React.FC = () => {
7171
)
7272
const [authenticated, setAuthenticated] = React.useState<boolean>(true)
7373
const [fullscreen, setFullscreen] = React.useState<boolean>(false)
74+
const [origin, setOrigin] = React.useState<string>(serverURL || '')
75+
76+
// Set the origin to the window.location.origin in useEffect to avoid hydration errors
77+
React.useEffect(() => {
78+
if (!serverURL) {
79+
setOrigin(window.location.origin)
80+
}
81+
}, [serverURL])
7482

7583
const trashParam = typeof initialData?.deletedAt === 'string'
7684

@@ -84,7 +92,7 @@ export const APIViewClient: React.FC = () => {
8492
const fetchURL = formatAdminURL({
8593
apiRoute,
8694
path: `${docEndpoint}?${params}`,
87-
serverURL: serverURL || window.location.origin,
95+
serverURL: origin,
8896
})
8997

9098
React.useEffect(() => {

packages/next/src/withPayload/withPayload.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export const withPayload = (nextConfig = {}, options = {}) => {
3434
env.NEXT_PUBLIC_ENABLE_ROUTER_CACHE_REFRESH = 'true'
3535
}
3636

37+
if (nextConfig.cacheComponents) {
38+
env.PAYLOAD_CACHE_COMPONENTS_ENABLED = 'true'
39+
}
40+
3741
const consoleWarn = console.warn
3842

3943
const sassWarningTexts = [

test/next.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const withBundleAnalyzer = bundleAnalyzer({
1414
export default withBundleAnalyzer(
1515
withPayload(
1616
{
17+
cacheComponents: process.env.PAYLOAD_CACHE_COMPONENTS === 'true',
1718
devIndicators: {
1819
position: 'bottom-right',
1920
},

test/playwright.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const filename = fileURLToPath(import.meta.url)
77
const dirname = path.dirname(filename)
88

99
dotenv.config({ path: path.resolve(dirname, 'test.env') })
10+
dotenv.config({ path: path.resolve(dirname, '..', '.env') })
1011

1112
const CI = process.env.CI === 'true'
1213

0 commit comments

Comments
 (0)