feat!: on demand rsc (#8364)

Currently, Payload renders all custom components on initial compile of
the admin panel. This is problematic for two key reasons:
1. Custom components do not receive contextual data, i.e. fields do not
receive their field data, edit views do not receive their document data,
etc.
2. Components are unnecessarily rendered before they are used

This was initially required to support React Server Components within
the Payload Admin Panel for two key reasons:
1. Fields can be dynamically rendered within arrays, blocks, etc.
2. Documents can be recursively rendered within a "drawer" UI, i.e.
relationship fields
3. Payload supports server/client component composition 

In order to achieve this, components need to be rendered on the server
and passed as "slots" to the client. Currently, the pattern for this is
to render custom server components in the "client config". Then when a
view or field is needed to be rendered, we first check the client config
for a "pre-rendered" component, otherwise render our client-side
fallback component.

But for the reasons listed above, this pattern doesn't exactly make
custom server components very useful within the Payload Admin Panel,
which is where this PR comes in. Now, instead of pre-rendering all
components on initial compile, we're able to render custom components
_on demand_, only as they are needed.

To achieve this, we've established [this
pattern](https://github.com/payloadcms/payload/pull/8481) of React
Server Functions in the Payload Admin Panel. With Server Functions, we
can iterate the Payload Config and return JSX through React's
`text/x-component` content-type. This means we're able to pass
contextual props to custom components, such as data for fields and
views.

## Breaking Changes

1. Add the following to your root layout file, typically located at
`(app)/(payload)/layout.tsx`:

    ```diff
    /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
    /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
    + import type { ServerFunctionClient } from 'payload'

    import config from '@payload-config'
    import { RootLayout } from '@payloadcms/next/layouts'
    import { handleServerFunctions } from '@payloadcms/next/utilities'
    import React from 'react'

    import { importMap } from './admin/importMap.js'
    import './custom.scss'

    type Args = {
      children: React.ReactNode
    }

+ const serverFunctions: ServerFunctionClient = async function (args) {
    +  'use server'
    +  return handleServerFunctions({
    +    ...args,
    +    config,
    +    importMap,
    +  })
    + }

    const Layout = ({ children }: Args) => (
      <RootLayout
        config={config}
        importMap={importMap}
    +  serverFunctions={serverFunctions}
      >
        {children}
      </RootLayout>
    )

    export default Layout
    ```

2. If you were previously posting to the `/api/form-state` endpoint, it
no longer exists. Instead, you'll need to invoke the `form-state` Server
Function, which can be done through the _new_ `getFormState` utility:

    ```diff
    - import { getFormState } from '@payloadcms/ui'
    - const { state } = await getFormState({
    -   apiRoute: '',
    -   body: {
    -     // ...
    -   },
    -   serverURL: ''
    - })

    + const { getFormState } = useServerFunctions()
    +
    + const { state } = await getFormState({
    +   // ...
    + })
    ```

## Breaking Changes

```diff
- useFieldProps()
- useCellProps()
```

More details coming soon.

---------

Co-authored-by: Alessio Gravili <alessio@gravili.de>
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
Co-authored-by: James <james@trbl.design>
This commit is contained in:
Jacob Fletcher
2024-11-11 13:59:05 -05:00
committed by GitHub
parent 3e954f45c7
commit c96fa613bc
657 changed files with 34245 additions and 21057 deletions

View File

@@ -1,10 +1,11 @@
import type { Page } from '@playwright/test'
import type { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
import { navigateToListCellLink } from './navigateToFirstCellLink.js'
export const navigateToDoc = async (page: Page, urlUtil: AdminUrlUtil) => {
await page.goto(urlUtil.list)
await page.waitForURL(urlUtil.list)
await navigateToListCellLink(page)
const cellLink = page.locator(`tbody tr:first-child td a`).first()
const linkURL = await cellLink.getAttribute('href')
await page.goto(`${urlUtil.serverURL}${linkURL}`)
await page.waitForURL(`**${linkURL}`)
}

View File

@@ -1,8 +0,0 @@
import type { Page } from '@playwright/test'
export async function navigateToListCellLink(page: Page) {
const cellLink = page.locator(`tbody tr:first-child td a`).first()
const linkURL = await cellLink.getAttribute('href')
await cellLink.click()
await page.waitForURL(`**${linkURL}`)
}

View File

@@ -1,71 +0,0 @@
import { createServer } from 'http'
import nextImport from 'next'
import path from 'path'
import { type Payload, getPayload } from 'payload'
import { wait } from 'payload/shared'
import { parse } from 'url'
import { runInit } from '../runInit.js'
import { createTestHooks } from '../testHooks.js'
import startMemoryDB from './startMemoryDB.js'
type Args = {
dirname: string
}
type Result = {
payload: Payload
serverURL: string
}
export async function initPayloadE2E({ dirname }: Args): Promise<Result> {
const testSuiteName = path.basename(dirname)
await runInit(testSuiteName, true)
const { beforeTest } = await createTestHooks(testSuiteName)
await beforeTest()
await startMemoryDB()
const { default: config } = await import(path.resolve(dirname, 'config.ts'))
const payload = await getPayload({ config })
const port = 3000
process.env.PORT = String(port)
process.env.PAYLOAD_CI_DEPENDENCY_CHECKER = 'true'
const serverURL = `http://localhost:${port}`
// @ts-expect-error
const app = nextImport({
dev: true,
hostname: 'localhost',
port,
dir: path.resolve(dirname, '../../'),
})
const handle = app.getRequestHandler()
let resolveServer
const serverPromise = new Promise((res) => (resolveServer = res))
// Need a custom server because calling nextDev straight
// starts up a child process, and payload.onInit() is called twice
// which seeds test data twice + other bad things.
// We initialize Payload above so we can have access to it in the tests
void app.prepare().then(() => {
createServer((req, res) => {
const parsedUrl = parse(req.url, true)
await handle(req, res, parsedUrl)
}).listen(port, () => {
resolveServer()
})
})
await serverPromise
await wait(port)
return { payload, serverURL }
}

View File

@@ -1,17 +1,11 @@
import { createServer } from 'http'
import nextImport from 'next'
import { spawn } from 'node:child_process'
import path, { dirname, resolve } from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath, parse } from 'url'
import { fileURLToPath } from 'url'
import type { GeneratedTypes } from './sdk/types.js'
import { runInitSeparateProcess } from '../runInitSeparateProcess.js'
import { createTestHooks } from '../testHooks.js'
import { getNextRootDir } from './getNextRootDir.js'
import { PayloadTestSDK } from './sdk/index.js'
import startMemoryDB from './startMemoryDB.js'
const _filename = fileURLToPath(import.meta.url)
const _dirname = dirname(_filename)
@@ -32,19 +26,12 @@ export async function initPayloadE2ENoConfig<T extends GeneratedTypes<T>>({
}: Args): Promise<Result<T>> {
const testSuiteName = path.basename(dirname)
await runInitSeparateProcess(testSuiteName, true)
const { beforeTest } = await createTestHooks(testSuiteName)
await beforeTest()
const port = 3000
process.env.PORT = String(port)
process.env.PAYLOAD_CI_DEPENDENCY_CHECKER = 'true'
const serverURL = `http://localhost:${port}`
await startMemoryDB()
const { rootDir } = getNextRootDir(testSuiteName)
if (prebuild) {
@@ -61,7 +48,9 @@ export async function initPayloadE2ENoConfig<T extends GeneratedTypes<T>>({
})
childProcess.on('close', (code) => {
if (code === 0) res()
if (code === 0) {
res()
}
rej()
})
})
@@ -69,38 +58,6 @@ export async function initPayloadE2ENoConfig<T extends GeneratedTypes<T>>({
process.env.NODE_OPTIONS = '--max-old-space-size=8192 --no-deprecation'
// @ts-expect-error
const app = nextImport({
dev: !prebuild,
hostname: 'localhost',
port,
dir: rootDir,
})
const handle = app.getRequestHandler()
let resolveServer
const serverPromise = new Promise((res) => (resolveServer = res))
// Need a custom server because calling nextDev straight
// starts up a child process, and payload.onInit() is called twice
// which seeds test data twice + other bad things.
// We initialize Payload above so we can have access to it in the tests
void app.prepare().then(() => {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
createServer(async (req, res) => {
const parsedUrl = parse(req.url, true)
await handle(req, res, parsedUrl)
}).listen(port, () => {
resolveServer()
})
})
await serverPromise
await wait(port)
return {
serverURL,
payload: new PayloadTestSDK<T>({ serverURL }),

View File

@@ -19,6 +19,7 @@ const handler: PayloadHandler = async (req) => {
snapshotKey: String(data.snapshotKey),
// uploadsDir can be string or stringlist
uploadsDir: data.uploadsDir as string | string[],
deleteOnly: data.deleteOnly,
})
return Response.json(

View File

@@ -4,19 +4,51 @@ export const reInitializeDB = async ({
serverURL,
snapshotKey,
uploadsDir,
deleteOnly,
}: {
deleteOnly?: boolean
serverURL: string
snapshotKey: string
uploadsDir?: string | string[]
}) => {
await fetch(`${serverURL}/api${path}`, {
method: 'post',
body: JSON.stringify({
snapshotKey,
uploadsDir,
}),
headers: {
'Content-Type': 'application/json',
},
})
const maxAttempts = 50
let attempt = 1
const startTime = Date.now()
while (attempt <= maxAttempts) {
try {
console.log(`Attempting to reinitialize DB (attempt ${attempt}/${maxAttempts})...`)
const response = await fetch(`${serverURL}/api${path}`, {
method: 'post',
body: JSON.stringify({
snapshotKey,
uploadsDir,
deleteOnly,
}),
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const timeTaken = Date.now() - startTime
console.log(`Successfully reinitialized DB (took ${timeTaken}ms)`)
return
} catch (error) {
console.error(`Failed to reinitialize DB: ${error.message}`)
if (attempt === maxAttempts) {
console.error('Max retry attempts reached. Giving up.')
throw error
}
console.log('Retrying in 3 seconds...')
await new Promise((resolve) => setTimeout(resolve, 3000))
attempt++
}
}
}

View File

@@ -19,10 +19,12 @@ export async function seedDB({
* Always seeds, instead of restoring from snapshot for consecutive test runs
*/
alwaysSeed = false,
deleteOnly,
}: {
_payload: Payload
alwaysSeed?: boolean
collectionSlugs: string[]
deleteOnly?: boolean
seedFunction: SeedFunction
/**
* Key to uniquely identify the kind of snapshot. Each test suite should pass in a unique key
@@ -64,7 +66,12 @@ export async function seedDB({
* This does not work if I run payload.db.init or payload.db.connect anywhere. Thus, when resetting the database, we are not dropping the schema, but are instead only deleting the table values
*/
let restored = false
if (!alwaysSeed && dbSnapshot[snapshotKey] && Object.keys(dbSnapshot[snapshotKey]).length) {
if (
!alwaysSeed &&
dbSnapshot[snapshotKey] &&
Object.keys(dbSnapshot[snapshotKey]).length &&
!deleteOnly
) {
await restoreFromSnapshot(_payload, snapshotKey, collectionSlugs)
/**
@@ -116,12 +123,25 @@ export async function seedDB({
await _payload.db.collections[collectionSlug].createIndexes()
}),
])
await Promise.all(
_payload.config.collections.map(async (coll) => {
await new Promise((resolve, reject) => {
_payload.db?.collections[coll.slug]?.ensureIndexes(function (err) {
if (err) {
reject(err)
}
resolve(true)
})
})
}),
)
}
/**
* If a snapshot was restored, we don't need to seed the database
*/
if (restored) {
if (restored || deleteOnly) {
return
}

View File

@@ -2,12 +2,13 @@ import { MongoMemoryReplSet } from 'mongodb-memory-server'
// eslint-disable-next-line no-restricted-exports
export default async () => {
console.log('Starting memory db...')
// @ts-expect-error
process.env.NODE_ENV = 'test'
process.env.PAYLOAD_DROP_DATABASE = 'true'
process.env.NODE_OPTIONS = '--no-deprecation'
process.env.DISABLE_PAYLOAD_HMR = 'true'
process.env.NODE_OPTIONS = '--no-deprecation'
if (
(!process.env.PAYLOAD_DATABASE || process.env.PAYLOAD_DATABASE === 'mongodb') &&

View File

@@ -0,0 +1,23 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { wait } from 'payload/shared'
import { POLL_TOPASS_TIMEOUT } from 'playwright.config.js'
export async function waitForAutoSaveToRunAndComplete(page: Page) {
await expect(async () => {
await expect(page.locator('.autosave:has-text("Saving...")')).toBeVisible()
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await wait(500)
await expect(async () => {
await expect(
page.locator('.autosave:has-text("Last saved less than a minute ago")'),
).toBeVisible()
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
}