Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
e64f7e3
test(form-state): add call-frequency benchmark harness
jacobsfletch Apr 28, 2026
161db2d
test(form-state): address review on benchmark harness
jacobsfletch Apr 28, 2026
15cb801
feat(payload): unified component index with kind classification
jacobsfletch Apr 28, 2026
9fe88c5
fix(payload): keep componentIndex out of client config and pin edge c…
jacobsfletch Apr 28, 2026
1f03c26
feat(payload): bundler-driven kind classifier for component index
jacobsfletch Apr 28, 2026
8bf1f12
refactor(payload): polish kind classifier per code review
jacobsfletch Apr 28, 2026
8cd6cd8
feat(payload): emit two import maps (server + client) at config build
jacobsfletch Apr 28, 2026
945ffbe
refactor(payload): polish two-map artifact per code review
jacobsfletch Apr 28, 2026
c90cda0
feat(ui): client-side import registry for admin runtime
jacobsfletch Apr 28, 2026
a2b3a67
refactor(ui): polish client import registry per code review
jacobsfletch Apr 28, 2026
08dac6c
refactor(payload): drop source-text use-client classifier; rely on bu…
jacobsfletch Apr 28, 2026
560e0d6
test(payload): cover wildcard-path and zero-component cases in buildI…
jacobsfletch Apr 28, 2026
4fb5cec
feat(payload): types for renderFields request/response
jacobsfletch Apr 28, 2026
666571f
feat(ui): implement narrow renderFields server function
jacobsfletch Apr 28, 2026
88222ed
refactor(ui): tighten renderFields args per code review
jacobsfletch Apr 28, 2026
c398d43
feat(next): expose renderFields as a server action
jacobsfletch Apr 28, 2026
d991e3e
feat(payload): build-time check that conditions are client-bundleable
jacobsfletch Apr 28, 2026
72f190d
feat(ui): client-side condition evaluator
jacobsfletch Apr 28, 2026
ef37198
feat(ui): visibility map React context
jacobsfletch Apr 28, 2026
7d2d4d9
fix: guard server-side admin.condition invocation against non-functio…
jacobsfletch Apr 28, 2026
2ffd361
test(form-state): integration test for path-valued client condition p…
jacobsfletch Apr 28, 2026
a19cfc6
feat(payload): add AdminValidateRef type for client-side validators
jacobsfletch Apr 29, 2026
8e91bc7
feat(payload): support object-form admin.validate refs in buildImport…
jacobsfletch Apr 29, 2026
f41b21e
test(payload): regression cases for admin.validate import-map collection
jacobsfletch Apr 29, 2026
a81df6e
test(payload): preserve admin.validate refs in client field projection
jacobsfletch Apr 29, 2026
fa5ad60
feat(ui): client-side runAdminValidate via import map
jacobsfletch Apr 29, 2026
51ee787
chore(ui): export createClientImportRegistry as a public subpath
jacobsfletch Apr 29, 2026
295a2e4
test(form-state): integration test for path-valued admin.validate pip…
jacobsfletch Apr 29, 2026
8d25780
feat(ui): detectStructural helper for array add/remove diffing
jacobsfletch Apr 29, 2026
e2f6771
feat(ui): diffVisibility helper for visibility-trigger detection
jacobsfletch Apr 29, 2026
2abf2de
feat(ui): decideCall algorithm for renderFields dispatch
jacobsfletch Apr 29, 2026
c21a314
chore(test): add jsdom vitest project + backfill provider render tests
jacobsfletch Apr 29, 2026
e73c9cc
refactor(ui): converge runAdminValidate ref resolution with parsePayl…
jacobsfletch Apr 29, 2026
39de072
feat(payload): emit admin-condition refs to runtime importMap and cli…
jacobsfletch Apr 29, 2026
c018283
feat(ui): hydrate clientImportRegistry from runtime importMap
jacobsfletch Apr 29, 2026
2488a44
feat(ui): wire client-side conditions into Form via VisibilityMapProv…
jacobsfletch Apr 29, 2026
ffbfd2a
feat(payload): emit admin-validate refs to clientConfig
jacobsfletch Apr 29, 2026
379e25f
feat(ui): wire runAdminValidate into Form via AdminValidateErrorsProv…
jacobsfletch Apr 29, 2026
25b07f1
test(form-state): assert skipValidation gates custom validate on edit…
jacobsfletch Apr 29, 2026
a84b08e
feat(payload): emit componentRefs to clientConfig for client-side dec…
jacobsfletch Apr 29, 2026
e027e8a
feat(ui): client componentsAt wrapper sharing server filter logic
jacobsfletch Apr 29, 2026
5d4a4f8
feat(ui): deriveRealizedFromFormState helper for client-side dispatch
jacobsfletch Apr 29, 2026
4e1c697
feat(ui): MERGE_RENDERED_FIELDS reducer action and renderFields client
jacobsfletch Apr 29, 2026
89c189a
feat(ui): client-side dispatch swap via decideCall + renderFields
jacobsfletch Apr 29, 2026
c57afcc
refactor(ui): dispatch swap polish + dev-mode warn for unresolved cli…
jacobsfletch Apr 29, 2026
0c5c30a
feat(payload): emit importMap.client.js with 'use client' directive
jacobsfletch Apr 29, 2026
2e499ad
fix(next): pass clientImportMap separately to RootProvider; keep impo…
jacobsfletch Apr 29, 2026
46fdc40
fix(test/app): import both importMap.js and importMap.client.js into …
jacobsfletch Apr 29, 2026
5a5f4b0
fix(ui): use ref for editSessionStartTime to prevent stale-closure he…
jacobsfletch Apr 29, 2026
47314c4
feat(ui): drop per-edit stale-data check from Edit view
jacobsfletch Apr 29, 2026
e073f49
fix(ui): default ADD_ROW to isLoading false to remove shimmer flash
jacobsfletch Apr 29, 2026
e80120b
feat(ui): WatchCondition consumes VisibilityMap for path-valued condi…
jacobsfletch Apr 29, 2026
4687ada
feat(ui): FieldError surfaces admin.validate errors live during edit
jacobsfletch Apr 29, 2026
8dd880c
feat(ui): split dispatch targets by kind in Edit onChange
jacobsfletch Apr 29, 2026
d0e8974
feat(ui): gate admin.validate on field.isModified to skip untouched-f…
jacobsfletch Apr 29, 2026
afbb61a
refactor(payload): extract shared walkSchema helper consumed by build…
jacobsfletch Apr 29, 2026
62430bd
test(ui): render-test coverage for WatchCondition and FieldError cons…
jacobsfletch Apr 29, 2026
7eaeb81
docs(payload): explain why walkSchema is distinct from traverseFields
jacobsfletch Apr 29, 2026
044524e
feat(payload): source-text use-client classifier with baseDir-relativ…
jacobsfletch Apr 29, 2026
d09fcf0
feat(payload): tag componentIndex entries with kind from source-text …
jacobsfletch Apr 29, 2026
1145fc7
feat(ui): Edit dispatch reads kind tag instead of runtime \$\$typeof …
jacobsfletch Apr 29, 2026
1786f72
fix(ui): strip entity slug from componentRefs before client component…
jacobsfletch Apr 29, 2026
1194e77
fix(ui): make render-fields server function actually render custom se…
jacobsfletch Apr 29, 2026
552358b
fix(ui): show shimmer on new array/block rows awaiting server-rendere…
jacobsfletch Apr 29, 2026
4258560
fix(ui): keep earlier array rows stable when adding new server-render…
jacobsfletch Apr 29, 2026
7b0f124
fix(ui): pass ClientField to client-mounted custom Field components
jacobsfletch Apr 29, 2026
78b7bec
feat(ui): mount client custom Field components synchronously on ADD_ROW
jacobsfletch Apr 29, 2026
5ec86c7
Merge perf/form-state-v4-phase-13-source-text-classifier into perf/fo…
jacobsfletch Apr 29, 2026
f352b0c
feat(payload): introduce path-valued AdminCondition; deprecate inline…
jacobsfletch Apr 30, 2026
e0fe790
refactor(payload): drop AdminCondition rename; use ConditionRef along…
jacobsfletch Apr 30, 2026
f175f94
fix(ui): expand wildcard admin.condition paths per realized row
jacobsfletch Apr 30, 2026
8c18d10
refactor(payload): drop sanitize-time inline-condition warn; keep v4 …
jacobsfletch Apr 30, 2026
580c905
fix(ui): don't pre-render custom Field components for fields hidden b…
jacobsfletch Apr 30, 2026
40d35ba
feat(ui): show shimmer for fields awaiting server custom Field render
jacobsfletch Apr 30, 2026
5900dfd
fix(ui): wrap shimmer in WatchCondition; document isomorphic conditions
jacobsfletch Apr 30, 2026
f33959c
cleanup
jacobsfletch Apr 30, 2026
f648d4f
fix(ui): drop row-level isLoading for arrays containing server custom…
jacobsfletch Apr 30, 2026
1e7bd1f
fix(ui): skip decideCall targets whose visibility is currently false
jacobsfletch Apr 30, 2026
bd94d48
Merge perf/form-state-v4-phase-14-conditions-demo into perf/form-stat…
jacobsfletch Apr 30, 2026
ce37c09
cleanup
jacobsfletch Apr 30, 2026
0141199
chore: drop narrow spec files added during form-state v4 work
jacobsfletch Apr 30, 2026
06b6f4d
chore: drop test-only deps + scaffolding from form-state v4 work
jacobsfletch Apr 30, 2026
5d2336b
Merge perf/form-state-v4-phase-15-spec-cleanup into perf/form-state-v4
jacobsfletch Apr 30, 2026
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(ui): implement narrow renderFields server function
  • Loading branch information
jacobsfletch committed Apr 28, 2026
commit 666571f2aabb4a9fad4dc404eb5ac8a8a55e08f1
7 changes: 5 additions & 2 deletions packages/payload/src/admin/forms/renderFieldsTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ export type RenderFieldsRequest = {

export type RenderedFieldEntry = {
path: string
/** RSC payload string (text/x-component) for the rendered component. */
payload: string
/**
* Rendered React element for the component. Serialized to RSC at the
* server-action boundary by Next.js — not by `renderFields` itself.
*/
payload: unknown
slot: RenderFieldsSlot
}

Expand Down
8 changes: 8 additions & 0 deletions packages/payload/src/exports/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
* Modules exported here are not part of the public API and are subject to change without notice and without a major version bump.
*/

export type {
RenderedFieldEntry,
RenderFieldsError,
RenderFieldsRequest,
RenderFieldsResponse,
RenderFieldsSlot,
RenderFieldsTarget,
} from '../admin/forms/renderFieldsTypes.js'
export { getExternalFile } from '../uploads/getExternalFile.js'
export { getRangeRequestInfo } from '../uploads/getRangeRequestInfo.js'
export { getSafeFileName } from '../uploads/getSafeFilename.js'
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@
"types": "./src/utilities/buildFieldSchemaMap/traverseFields.ts",
"default": "./src/utilities/buildFieldSchemaMap/traverseFields.ts"
},
"./utilities/renderFields": {
"import": "./src/utilities/renderFields.ts",
"types": "./src/utilities/renderFields.ts",
"default": "./src/utilities/renderFields.ts"
},
"./forms/fieldSchemasToFormState": {
"import": "./src/forms/fieldSchemasToFormState/index.tsx",
"types": "./src/forms/fieldSchemasToFormState/index.tsx",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ImportMap, PayloadRequest, SanitizedConfig } from 'payload'
import type React from 'react'

import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js'

type ComponentSlot =
| 'afterInput'
| 'beforeInput'
| 'Description'
| 'Error'
| 'Field'
| 'Label'
| 'RowLabel'

type RenderSingleFieldArgs = {
/** Module path/specifier of the component as recorded in the component index. */
componentPath: string
config: SanitizedConfig
/** Field path, e.g. `posts.renderTracker`. Forwarded to the component as `path`/`schemaPath`. */
fieldPath: string
importMap: ImportMap
req: PayloadRequest
slot: ComponentSlot
}

/**
* Renders a single component referenced by the component index, returning a
* React element. Intentionally narrow: it does not walk the schema, build a
* client field, load document data, or compute defaults. Callers that need
* those concerns should layer them on top.
*/
export async function renderSingleField(args: RenderSingleFieldArgs): Promise<React.ReactNode> {
const { componentPath, fieldPath, importMap, req } = args

const clientProps = {
path: fieldPath,
schemaPath: fieldPath,
}

const serverProps = {
i18n: req.i18n,
payload: req.payload,
req,
user: req.user,
}

return RenderServerComponent({
clientProps,
Component: { path: componentPath },
importMap,
serverProps,
})
}
73 changes: 73 additions & 0 deletions packages/ui/src/utilities/renderFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { Payload, PayloadRequest } from 'payload'
import type { RenderFieldsRequest, RenderFieldsResponse } from 'payload/internal'

import { renderSingleField } from '../forms/fieldSchemasToFormState/renderSingleField.js'

type RenderFieldsArgs = {
payload: Payload
req: PayloadRequest
request: RenderFieldsRequest
}

/**
* Renders the components requested by `request.render` and returns their
* React elements. Resolution is driven by `payload.config.componentIndex` —
* this function does NOT walk the schema, load document data, or compute
* defaults. Errors are isolated per target so a single failure does not
* abort the batch.
*/
export async function renderFields(args: RenderFieldsArgs): Promise<RenderFieldsResponse> {
const { payload, req, request } = args
const index = payload.config.componentIndex
const importMap = payload.importMap

const rendered: RenderFieldsResponse['rendered'] = []
const errors: NonNullable<RenderFieldsResponse['errors']> = []

if (!index) {
for (const target of request.render) {
errors.push({
message: 'componentIndex not configured on payload.config',
path: target.path,
slot: target.slot,
})
}
return errors.length > 0 ? { errors, rendered } : { rendered }
}

for (const target of request.render) {
try {
const matched = index
.componentsAt(target.path)
.find((c) => c.slot === target.slot && c.path === target.path)

if (!matched) {
errors.push({
message: 'No registered component at path/slot',
path: target.path,
slot: target.slot,
})
continue
}

const element = await renderSingleField({
componentPath: matched.componentPath,
config: payload.config,
fieldPath: matched.path,
importMap,
req,
slot: matched.slot,
})

rendered.push({ path: target.path, payload: element, slot: target.slot })
} catch (err) {
errors.push({
message: err instanceof Error ? err.message : String(err),
path: target.path,
slot: target.slot,
})
}
}

return errors.length > 0 ? { errors, rendered } : { rendered }
}
102 changes: 102 additions & 0 deletions test/form-state/renderFields.int.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { Payload } from 'payload'

import { renderFields } from '@payloadcms/ui/utilities/renderFields'
import path from 'path'
import { createLocalReq } from 'payload'
import { fileURLToPath } from 'url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'

import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js'
import { ArrayRowLabel } from './collections/Posts/ArrayRowLabel.js'
import { RenderTracker } from './collections/Posts/RenderTracker.js'
import { CustomTextField } from './collections/Posts/TextField.js'

const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)

let payload: Payload

describe('renderFields server function', () => {
beforeAll(async () => {
;({ payload } = await initPayloadInt(dirname, undefined, true))

// Integration tests run outside Next.js, so the admin layout never loads
// the generated `app/(payload)/admin/importMap.js` into `payload.importMap`.
// Build the map directly from the components referenced by the form-state
// config — the auto-injected `@payloadcms/next/rsc` entries aren't
// resolvable in the int runtime, but they are not exercised by these tests.
payload.importMap = {
'./collections/Posts/RenderTracker.js#RenderTracker': RenderTracker,
'./collections/Posts/TextField.js#CustomTextField': CustomTextField,
'./collections/Posts/ArrayRowLabel.js#ArrayRowLabel': ArrayRowLabel,
}
})

afterAll(async () => {
await payload.destroy()
})

it('renders a single component and returns its element', async () => {
const req = await createLocalReq({ user: null }, payload)
const result = await renderFields({
payload,
req,
request: {
collectionSlug: 'posts',
render: [{ path: 'posts.renderTracker', slot: 'Field' }],
},
})

expect(result.rendered).toHaveLength(1)
expect(result.rendered[0]).toMatchObject({
path: 'posts.renderTracker',
slot: 'Field',
})
expect(result.rendered[0]!.payload).toBeDefined()
expect(result.errors).toBeUndefined()
})

it('isolates per-component errors', async () => {
const req = await createLocalReq({ user: null }, payload)
const result = await renderFields({
payload,
req,
request: {
collectionSlug: 'posts',
render: [
{ path: 'posts.renderTracker', slot: 'Field' },
{ path: 'posts.does-not-exist', slot: 'Field' },
],
},
})

expect(result.rendered).toHaveLength(1)
expect(result.errors).toHaveLength(1)
expect(result.errors![0]!.path).toBe('posts.does-not-exist')
})

it('does not walk the config or compute defaults', async () => {
const req = await createLocalReq({ user: null }, payload)
// Warm up to avoid first-call import/cache penalties.
await renderFields({
payload,
req,
request: {
collectionSlug: 'posts',
render: [{ path: 'posts.renderTracker', slot: 'Field' }],
},
})

const t0 = performance.now()
await renderFields({
payload,
req,
request: {
collectionSlug: 'posts',
render: [{ path: 'posts.renderTracker', slot: 'Field' }],
},
})
const elapsed = performance.now() - t0
expect(elapsed).toBeLessThan(200)
})
})