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
fix(ui): make render-fields server function actually render custom se…
…rver Field components

Three bugs surface together when an array adds a row whose inner field
has admin.components.Field set to a server component:

1. Slug mismatch: server-side componentIndex paths are entity-slug
   prefixed ('arrays.serverArray.*.text'), but client-side dispatch uses
   formState-relative paths ('serverArray.0.text'). The render-fields
   request landed on the server with slug-stripped paths, componentsAt()
   segment-prefix match failed, every target produced 'No registered
   component'. Fix on the client side: re-prefix paths going out, strip
   on the way back.

2. renderSingleField was too narrow: it built minimal serverProps without
   clientField, field schema, schemaMap, etc. Custom Field components
   that wrap a base @payloadcms/ui field (TextField, NumberField, etc.)
   crashed destructuring 'admin' from undefined. Replace it with a
   proper call into renderField (the same path used for initial form-
   state construction), wired through getSchemaMap + getClientSchemaMap
   so server custom fields receive everything the legacy renderer gives
   them. renderSingleField was unused outside renderFields, so it's
   removed.

3. renderField.tsx uses JSX without 'import React' — fine in Next.js
   where React is global, fatal in non-Next callers (vitest int tests,
   the new render-fields path through renderField). Add the React
   import. The eslint-unused-vars override is intentional; tsc preserves
   the binding so the classic JSX runtime can resolve
   'React.createElement'.

Verified in browser: Add Server Array fires render-fields with
{path: arrays.serverArray.0.text, slot: Field}, server executes
ServerTextField (pid + hostname proof), default Field never replaces it
on heartbeat. All form-state int tests (37) and unit tests (1409 + 39
react) pass.
  • Loading branch information
jacobsfletch committed Apr 29, 2026
commit 1194e7710fd53610fe9891fe5d25e4318062bbe0
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import type {
import { getTranslation } from '@payloadcms/translations'
import { createClientField, MissingEditorProp } from 'payload'
import { fieldIsHiddenOrDisabled } from 'payload/shared'
// JSX in this module compiles to `React.createElement(...)` (classic runtime).
// Add the React binding so node-side callers (e.g. the `renderFields` server
// function in non-Next environments) don't trip over `React is not defined`.

import React from 'react'

import type { RenderFieldMethod } from './types.js'

Expand Down

This file was deleted.

141 changes: 129 additions & 12 deletions packages/ui/src/utilities/renderFields.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { PayloadRequest, ServerFunction } from 'payload'
import type { Field, FieldState, PayloadRequest, ServerFunction } from 'payload'
import type { RenderFieldsRequest, RenderFieldsResponse } from 'payload/internal'

import { renderSingleField } from '../forms/fieldSchemasToFormState/renderSingleField.js'
import { SLOT_TO_CUSTOM_COMPONENT_KEY } from '../forms/deriveRealized.js'
import { renderField } from '../forms/fieldSchemasToFormState/renderField.js'
import { getClientConfig } from './getClientConfig.js'
import { getClientSchemaMap } from './getClientSchemaMap.js'
import { getSchemaMap } from './getSchemaMap.js'

type RenderFieldsArgs = {
req: PayloadRequest
Expand All @@ -10,15 +14,21 @@ type RenderFieldsArgs = {

/**
* 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.
* React elements. Each target carries an entity-slug-prefixed path (e.g.
* `posts.title` or `arrays.serverArray.0.text`); the slug-stripped form
* lives client-side in formState/visibility, the dispatcher in
* `Edit/index.tsx` re-prefixes when sending the request.
*
* Rendering reuses `renderField` (the same code path used during initial
* form-state construction) so server custom Field components receive the
* full `clientField`, `field`, `fieldSchemaMap`, etc. without any new
* server-side schema walking. Errors are isolated per target so a single
* failure does not abort the batch.
*/
// eslint-disable-next-line @typescript-eslint/require-await
export async function renderFields(args: RenderFieldsArgs): Promise<RenderFieldsResponse> {
const { req, request } = args
const index = req.payload.config.componentIndex
const importMap = req.payload.importMap

const rendered: RenderFieldsResponse['rendered'] = []
const errors: NonNullable<RenderFieldsResponse['errors']> = []
Expand All @@ -34,8 +44,49 @@ export async function renderFields(args: RenderFieldsArgs): Promise<RenderFields
return errors.length > 0 ? { errors, rendered } : { rendered }
}

const collectionSlug = request.collectionSlug
const globalSlug = request.globalSlug
const entitySlug = collectionSlug ?? globalSlug

if (!entitySlug) {
for (const target of request.render) {
errors.push({
message: 'render-fields request missing collectionSlug/globalSlug',
path: target.path,
slot: target.slot,
})
}
return { errors, rendered }
}

const schemaMap = getSchemaMap({
collectionSlug,
config: req.payload.config,
globalSlug,
i18n: req.i18n,
})

const clientConfig = getClientConfig({
config: req.payload.config,
i18n: req.i18n,
importMap: req.payload.importMap,
user: req.user,
})

const clientSchemaMap = getClientSchemaMap({
collectionSlug,
config: clientConfig,
globalSlug,
i18n: req.i18n,
payload: req.payload,
schemaMap,
})

for (const target of request.render) {
try {
// The component index already validates that *something* is registered
// at this path/slot. We do an early lookup so unknown targets fail
// fast with a clear message rather than landing in renderField.
const matched = index
.componentsAt(target.path)
.find((c) => c.slot === target.slot && c.path === target.path)
Expand All @@ -49,14 +100,80 @@ export async function renderFields(args: RenderFieldsArgs): Promise<RenderFields
continue
}

const element = await renderSingleField({
componentPath: matched.componentPath,
fieldPath: matched.path,
importMap,
// `target.path` carries row indices (e.g. `arrays.serverArray.0.text`).
// The schema map is keyed by schema-shape paths with numeric segments
// collapsed (`arrays.serverArray.text`). Build the schema key here.
const targetSegments = target.path.split('.')
const schemaSegments = targetSegments.filter((seg) => !/^\d+$/.test(seg))
const schemaPath = schemaSegments.join('.')

// `buildFieldSchemaMap.traverseFields` stores the Field directly under
// the schema path; the top-level entity entry is the only one with
// `{ fields }` shape (set in buildFieldSchemaMap/index.ts).
const resolvedField = schemaMap.get(schemaPath) as Field | undefined

if (!resolvedField) {
errors.push({
message: `No field schema found at schemaPath: ${schemaPath}`,
path: target.path,
slot: target.slot,
})
continue
}

const fieldState: FieldState = {}
// Strip the entity slug from `path` going into renderField — its
// contract is collection-relative (matches what the dispatcher
// ultimately keys into formState).
const slugStrip = `${entitySlug}.`
const renderPath = target.path.startsWith(slugStrip)
? target.path.slice(slugStrip.length)
: target.path

renderField({
clientFieldSchemaMap: clientSchemaMap,
collectionSlug: collectionSlug ?? '-',
data: {},
fieldConfig: resolvedField,
fieldSchemaMap: schemaMap,
fieldState,
// Force `createClientField` rather than the schema-map lookup —
// the lookup path requires a clientFieldSchemaMap key that
// exactly matches `schemaPath`, which it doesn't always for
// leaf fields under arrays/blocks. Creating directly from the
// resolved Field config sidesteps that.
forceCreateClientField: true,
formState: {},
indexPath: '',
lastRenderedPath: '',
operation: 'create',
parentPath: '',
parentSchemaPath: '',
path: renderPath,
permissions: true,
preferences: { fields: {} },
previousFieldState: undefined,
renderAllFields: true,
req,
schemaPath,
siblingData: {},
})

rendered.push({ path: target.path, payload: element, slot: target.slot })
const customKey = SLOT_TO_CUSTOM_COMPONENT_KEY[target.slot]
const payload = (fieldState.customComponents as Record<string, unknown> | undefined)?.[
customKey
]

if (payload === undefined) {
errors.push({
message: `renderField produced no ${target.slot} component`,
path: target.path,
slot: target.slot,
})
continue
}

rendered.push({ path: target.path, payload, slot: target.slot })
} catch (err) {
errors.push({
message: err instanceof Error ? err.message : String(err),
Expand Down
20 changes: 18 additions & 2 deletions packages/ui/src/views/Edit/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -654,17 +654,33 @@ export function DefaultEditView({

let serverRendered: RenderedFieldsResult['rendered'] = []
if (serverRender.length > 0) {
// Server-side `componentIndex` paths are entity-slug-prefixed (built by
// `walkSchema(config, …)` which seeds with `[collection.slug]`). The
// client-side `componentIndex` is slug-stripped to match formState
// paths, but the request to `render-fields` lands on the server's
// index — so re-prefix paths going out and strip them on the way back
// before they're keyed into formState (`MERGE_RENDERED_FIELDS` uses
// `entry.path` as the state key).
const slugPrefix = collectionSlug ?? globalSlug
const addSlug = (p: string): string => (slugPrefix ? `${slugPrefix}.${p}` : p)
const stripSlug = (p: string): string => {
if (!slugPrefix) {
return p
}
const prefix = `${slugPrefix}.`
return p.startsWith(prefix) ? p.slice(prefix.length) : p
}
const renderResult = await renderFields({
collectionSlug,
documentId: id,
globalSlug,
render: serverRender.map(({ path, slot }) => ({ path, slot })),
render: serverRender.map(({ path, slot }) => ({ path: addSlug(path), slot })),
signal: controller.signal,
})

if (renderResult?.rendered?.length) {
serverRendered = renderResult.rendered.map((entry) => ({
path: entry.path,
path: stripSlug(entry.path),
payload: entry.payload as React.ReactNode,
slot: entry.slot,
}))
Expand Down