Skip to content

Commit c96fa61

Browse files
jacobsfletchAlessioGrJarrodMFleschjmikrut
authored
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](#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 <[email protected]> Co-authored-by: Jarrod Flesch <[email protected]> Co-authored-by: James <[email protected]>
1 parent 3e954f4 commit c96fa61

File tree

657 files changed

+34172
-20984
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

657 files changed

+34172
-20984
lines changed

app/(payload)/layout.tsx

+14-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
22
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
3-
import configPromise from '@payload-config'
4-
import { RootLayout } from '@payloadcms/next/layouts'
53
// import '@payloadcms/ui/styles.css' // Uncomment this line if `@payloadcms/ui` in `tsconfig.json` points to `/ui/dist` instead of `/ui/src`
4+
import type { ServerFunctionClient } from 'payload'
5+
6+
import config from '@payload-config'
7+
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
68
import React from 'react'
79

810
import { importMap } from './admin/importMap.js'
@@ -12,8 +14,17 @@ type Args = {
1214
children: React.ReactNode
1315
}
1416

17+
const serverFunction: ServerFunctionClient = async function (args) {
18+
'use server'
19+
return handleServerFunctions({
20+
...args,
21+
config,
22+
importMap,
23+
})
24+
}
25+
1526
const Layout = ({ children }: Args) => (
16-
<RootLayout config={configPromise} importMap={importMap}>
27+
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
1728
{children}
1829
</RootLayout>
1930
)

docs/admin/fields.mdx

-1
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,6 @@ The following additional properties are also provided to the `field` prop:
228228

229229
| Property | Description |
230230
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
231-
| **`_isPresentational`** | A boolean indicating that the field is purely visual and does not directly affect data or change data shape, i.e. the [UI Field](../fields/ui). |
232231
| **`_path`** | A string representing the direct, dynamic path to the field at runtime, i.e. `myGroup.myArray[0].myField`. |
233232
| **`_schemaPath`** | A string representing the direct, static path to the [Field Config](../fields/overview), i.e. `myGroup.myArray.myField` |
234233

docs/lexical/converters.mdx

+13-5
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,12 @@ const yourEditorState: SerializedEditorState // <= your current editor state her
370370

371371
// Import editor state into your headless editor
372372
try {
373-
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState)) // This should commit the editor state immediately
373+
headlessEditor.update(
374+
() => {
375+
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
376+
},
377+
{ discrete: true }, // This should commit the editor state immediately
378+
)
374379
} catch (e) {
375380
logger.error({ err: e }, 'ERROR parsing editor state')
376381
}
@@ -382,8 +387,6 @@ headlessEditor.getEditorState().read(() => {
382387
})
383388
```
384389

385-
The `.setEditorState()` function immediately updates your editor state. Thus, there's no need for the `discrete: true` flag when reading the state afterward.
386-
387390
## Lexical => Plain Text
388391

389392
Export content from the Lexical editor into plain text using these steps:
@@ -401,8 +404,13 @@ const yourEditorState: SerializedEditorState // <= your current editor state her
401404

402405
// Import editor state into your headless editor
403406
try {
404-
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState)) // This should commit the editor state immediately
405-
} catch (e) {
407+
headlessEditor.update(
408+
() => {
409+
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
410+
},
411+
{ discrete: true }, // This should commit the editor state immediately
412+
)
413+
} catch (e) {
406414
logger.error({ err: e }, 'ERROR parsing editor state')
407415
}
408416

examples/custom-components/src/app/(payload)/layout.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
22
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
3-
import configPromise from '@payload-config'
3+
import config from '@payload-config'
44
import '@payloadcms/next/css'
55
import { RootLayout } from '@payloadcms/next/layouts'
66
import React from 'react'
@@ -13,7 +13,7 @@ type Args = {
1313
}
1414

1515
const Layout = ({ children }: Args) => (
16-
<RootLayout config={configPromise} importMap={importMap}>
16+
<RootLayout config={config} importMap={importMap}>
1717
{children}
1818
</RootLayout>
1919
)

examples/hierarchy/src/app/(payload)/layout.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
2+
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
23
import configPromise from '@payload-config'
34
import '@payloadcms/next/css'
45
import { RootLayout } from '@payloadcms/next/layouts'
5-
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
66
import React from 'react'
77

88
import './custom.scss'

examples/multi-tenant/src/app/(payload)/layout.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
22
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
3-
import configPromise from '@payload-config'
3+
import config from '@payload-config'
44
import '@payloadcms/next/css'
55
import { RootLayout } from '@payloadcms/next/layouts'
66
import React from 'react'
@@ -13,7 +13,7 @@ type Args = {
1313
}
1414

1515
const Layout = ({ children }: Args) => (
16-
<RootLayout config={configPromise} importMap={importMap}>
16+
<RootLayout config={config} importMap={importMap}>
1717
{children}
1818
</RootLayout>
1919
)

next.config.mjs

+5
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ const config = withBundleAnalyzer(
1919
typescript: {
2020
ignoreBuildErrors: true,
2121
},
22+
experimental: {
23+
serverActions: {
24+
bodySizeLimit: '5mb',
25+
},
26+
},
2227
env: {
2328
PAYLOAD_CORE_DEV: 'true',
2429
ROOT_DIR: path.resolve(dirname),

package.json

+4-3
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,20 @@
5858
"dev:generate-importmap": "pnpm runts ./test/generateImportMap.ts",
5959
"dev:generate-types": "pnpm runts ./test/generateTypes.ts",
6060
"dev:postgres": "cross-env PAYLOAD_DATABASE=postgres pnpm runts ./test/dev.ts",
61+
"dev:prod": "cross-env NODE_OPTIONS=--no-deprecation tsx ./test/dev.ts --prod",
6162
"dev:vercel-postgres": "cross-env PAYLOAD_DATABASE=vercel-postgres pnpm runts ./test/dev.ts",
6263
"devsafe": "node ./scripts/delete-recursively.js '**/.next' && pnpm dev",
6364
"docker:restart": "pnpm docker:stop --remove-orphans && pnpm docker:start",
6465
"docker:start": "docker compose -f packages/plugin-cloud-storage/docker-compose.yml up -d",
6566
"docker:stop": "docker compose -f packages/plugin-cloud-storage/docker-compose.yml down",
6667
"force:build": "pnpm run build:core:force",
6768
"lint": "turbo run lint --concurrency 1 --continue",
68-
"lint-staged": "lint-staged",
69+
"lint-staged": "node ./scripts/run-lint-staged.js",
6970
"lint:fix": "turbo run lint:fix --concurrency 1 --continue",
7071
"obliterate-playwright-cache-macos": "rm -rf ~/Library/Caches/ms-playwright && find /System/Volumes/Data/private/var/folders -type d -name 'playwright*' -exec rm -rf {} +",
7172
"prepare": "husky",
72-
"prepare-run-test-against-prod": "pnpm bf && rm -rf test/packed && rm -rf test/node_modules && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
73-
"prepare-run-test-against-prod:ci": "rm -rf test/node_modules && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
73+
"prepare-run-test-against-prod": "pnpm bf && rm -rf test/packed && rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
74+
"prepare-run-test-against-prod:ci": "rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
7475
"reinstall": "pnpm clean:all && pnpm install",
7576
"release:alpha": "pnpm runts ./scripts/release.ts --bump prerelease --tag alpha",
7677
"release:beta": "pnpm runts ./scripts/release.ts --bump prerelease --tag beta",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { QueryOptions } from 'mongoose'
2+
import type { CountGlobalVersions, PayloadRequest } from 'payload'
3+
4+
import { flattenWhereToOperators } from 'payload'
5+
6+
import type { MongooseAdapter } from './index.js'
7+
8+
import { withSession } from './withSession.js'
9+
10+
export const countGlobalVersions: CountGlobalVersions = async function countGlobalVersions(
11+
this: MongooseAdapter,
12+
{ global, locale, req = {} as PayloadRequest, where },
13+
) {
14+
const Model = this.versions[global]
15+
const options: QueryOptions = await withSession(this, req)
16+
17+
let hasNearConstraint = false
18+
19+
if (where) {
20+
const constraints = flattenWhereToOperators(where)
21+
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
22+
}
23+
24+
const query = await Model.buildQuery({
25+
locale,
26+
payload: this.payload,
27+
where,
28+
})
29+
30+
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.
31+
const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0
32+
33+
if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) {
34+
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
35+
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
36+
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
37+
// the correct indexed field
38+
options.hint = {
39+
_id: 1,
40+
}
41+
}
42+
43+
const result = await Model.countDocuments(query, options)
44+
45+
return {
46+
totalDocs: result,
47+
}
48+
}
+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { QueryOptions } from 'mongoose'
2+
import type { CountVersions, PayloadRequest } from 'payload'
3+
4+
import { flattenWhereToOperators } from 'payload'
5+
6+
import type { MongooseAdapter } from './index.js'
7+
8+
import { withSession } from './withSession.js'
9+
10+
export const countVersions: CountVersions = async function countVersions(
11+
this: MongooseAdapter,
12+
{ collection, locale, req = {} as PayloadRequest, where },
13+
) {
14+
const Model = this.versions[collection]
15+
const options: QueryOptions = await withSession(this, req)
16+
17+
let hasNearConstraint = false
18+
19+
if (where) {
20+
const constraints = flattenWhereToOperators(where)
21+
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
22+
}
23+
24+
const query = await Model.buildQuery({
25+
locale,
26+
payload: this.payload,
27+
where,
28+
})
29+
30+
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.
31+
const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0
32+
33+
if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) {
34+
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
35+
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
36+
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
37+
// the correct indexed field
38+
options.hint = {
39+
_id: 1,
40+
}
41+
}
42+
43+
const result = await Model.countDocuments(query, options)
44+
45+
return {
46+
totalDocs: result,
47+
}
48+
}

packages/db-mongodb/src/index.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import type { CollectionModel, GlobalModel, MigrateDownArgs, MigrateUpArgs } fro
1212

1313
import { connect } from './connect.js'
1414
import { count } from './count.js'
15+
import { countGlobalVersions } from './countGlobalVersions.js'
16+
import { countVersions } from './countVersions.js'
1517
import { create } from './create.js'
1618
import { createGlobal } from './createGlobal.js'
1719
import { createGlobalVersion } from './createGlobalVersion.js'
@@ -154,7 +156,6 @@ export function mongooseAdapter({
154156
collections: {},
155157
connection: undefined,
156158
connectOptions: connectOptions || {},
157-
count,
158159
disableIndexHints,
159160
globals: undefined,
160161
mongoMemoryServer,
@@ -166,6 +167,9 @@ export function mongooseAdapter({
166167
beginTransaction: transactionOptions === false ? defaultBeginTransaction() : beginTransaction,
167168
commitTransaction,
168169
connect,
170+
count,
171+
countGlobalVersions,
172+
countVersions,
169173
create,
170174
createGlobal,
171175
createGlobalVersion,

packages/db-mongodb/src/utilities/buildJoinAggregation.ts

+5-7
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import type { PipelineStage } from 'mongoose'
22
import type { CollectionSlug, JoinQuery, SanitizedCollectionConfig, Where } from 'payload'
33

4-
import { combineQueries } from 'payload'
5-
64
import type { MongooseAdapter } from '../index.js'
75

86
import { buildSortParam } from '../queries/buildSortParam.js'
@@ -60,19 +58,19 @@ export const buildJoinAggregation = async ({
6058
for (const join of joinConfig[slug]) {
6159
const joinModel = adapter.collections[join.field.collection]
6260

63-
if (projection && !projection[join.schemaPath]) {
61+
if (projection && !projection[join.joinPath]) {
6462
continue
6563
}
6664

67-
if (joins?.[join.schemaPath] === false) {
65+
if (joins?.[join.joinPath] === false) {
6866
continue
6967
}
7068

7169
const {
7270
limit: limitJoin = join.field.defaultLimit ?? 10,
7371
sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort,
7472
where: whereJoin,
75-
} = joins?.[join.schemaPath] || {}
73+
} = joins?.[join.joinPath] || {}
7674

7775
const sort = buildSortParam({
7876
config: adapter.payload.config,
@@ -105,7 +103,7 @@ export const buildJoinAggregation = async ({
105103

106104
if (adapter.payload.config.localization && locale === 'all') {
107105
adapter.payload.config.localization.localeCodes.forEach((code) => {
108-
const as = `${versions ? `version.${join.schemaPath}` : join.schemaPath}${code}`
106+
const as = `${versions ? `version.${join.joinPath}` : join.joinPath}${code}`
109107

110108
aggregate.push(
111109
{
@@ -146,7 +144,7 @@ export const buildJoinAggregation = async ({
146144
} else {
147145
const localeSuffix =
148146
join.field.localized && adapter.payload.config.localization && locale ? `.${locale}` : ''
149-
const as = `${versions ? `version.${join.schemaPath}` : join.schemaPath}${localeSuffix}`
147+
const as = `${versions ? `version.${join.joinPath}` : join.joinPath}${localeSuffix}`
150148

151149
aggregate.push(
152150
{

packages/db-mongodb/src/utilities/handleError.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ export const handleError = ({
1919
collection,
2020
errors: [
2121
{
22-
field: Object.keys(error.keyValue)[0],
2322
message: req.t('error:valueMustBeUnique'),
23+
path: Object.keys(error.keyValue)[0],
2424
},
2525
],
2626
global,

packages/db-postgres/src/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
beginTransaction,
55
commitTransaction,
66
count,
7+
countGlobalVersions,
8+
countVersions,
79
create,
810
createGlobal,
911
createGlobalVersion,
@@ -126,6 +128,8 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
126128
convertPathToJSONTraversal,
127129
count,
128130
countDistinct,
131+
countGlobalVersions,
132+
countVersions,
129133
create,
130134
createGlobal,
131135
createGlobalVersion,

packages/db-sqlite/src/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
beginTransaction,
66
commitTransaction,
77
count,
8+
countGlobalVersions,
9+
countVersions,
810
create,
911
createGlobal,
1012
createGlobalVersion,
@@ -114,6 +116,8 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj<SQLiteAdapter> {
114116
convertPathToJSONTraversal,
115117
count,
116118
countDistinct,
119+
countGlobalVersions,
120+
countVersions,
117121
create,
118122
createGlobal,
119123
createGlobalVersion,

packages/db-vercel-postgres/src/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
beginTransaction,
55
commitTransaction,
66
count,
7+
countGlobalVersions,
8+
countVersions,
79
create,
810
createGlobal,
911
createGlobalVersion,
@@ -127,6 +129,8 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<Verce
127129
convertPathToJSONTraversal,
128130
count,
129131
countDistinct,
132+
countGlobalVersions,
133+
countVersions,
130134
create,
131135
createGlobal,
132136
createGlobalVersion,

0 commit comments

Comments
 (0)