fix(ui): remove nested row components when reducing form state to serializable fields#17427
Open
dotCooCoo wants to merge 1 commit into
Open
Conversation
…ializable fields Delegate reduceToSerializableFields to deepCopyObjectSimpleWithoutReactComponents so React component trees are pruned at every depth, including rows[i].customComponents.RowLabel. This severs the circular reference (BasePayload -> db -> payload) that made plugin-seo's generate-url / generate-title requests throw "Converting circular structure to JSON". Fixes payloadcms#16786
dotCooCoo
requested review from
AlessioGr,
JarrodMFlesch and
jacobsfletch
as code owners
July 21, 2026 13:30
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
On the SEO tab, opening a document that has an array (or blocks) field with a custom
RowLabelcomponent and a populated relationship row throws when the preview/generate fields fire their request:Root cause
Form state is a flat
{ [path]: FieldState }map, and it carries React nodes at two depths:FieldState.customComponents—Field/Label/Error/Description/ …FieldState.rows[i].customComponents.RowLabel— the per-row label componentreduceToSerializableFieldsshallow-copied each field and deleted only the top-levelcustomComponentsandvalidatekeys. It never descended intorows[i], so a renderedRowLabelelement survived into the POST body sent to/api/plugin-seo/generate-url(andgenerate-title/generate-description/generate-image). When the row holds a populated relationship, that element's props reach back into the running Payload instance (BasePayload -> db -> payload -> …), andJSON.stringifyon the request body throws.A name-based blocklist is the wrong shape for this: it has to enumerate every property that might hold a component, at every depth, and it silently regresses the moment a new component slot is added.
The fix
Delegate
reduceToSerializableFieldstodeepCopyObjectSimpleWithoutReactComponents— the same helperpackages/ui/src/forms/Form/index.tsxalready uses to serializecontextRef.current.fieldsbefore sending form state to the server.That helper identifies React elements by their
$$typeofsymbol and returnsundefinedfor them at the top of each recursive call, before it visits their props. This means:customComponentsand the per-rowrows[i].customComponents.RowLabel— so the flat map no longer smuggles a rendered tree onto the wire.BasePayloadcycle behind it is never reached.generateURL/generateTitleimplementation actually reads is preserved — field values,initialValue, row metadata (id/blockType/collapsed),filterOptions,valid, error state — withDatepreserved and BSON ObjectIds normalized to hex, matching the form's own serialization.Filevalues are dropped (excludeFiles: true), the same as the form does.The change is centralized in the shared util, so every consumer of
reduceToSerializableFieldsis fixed at once. The four plugin-seo field components are untouched, and there are nopackage.json/ README changes.Prior attempts
This supersedes two earlier attempts that patched the blocklist inside plugin-seo instead of fixing the shared util:
customComponentsfromrows[i], without tests.Both correctly identified that the per-row component tree was the leak; thanks to the #16786 reporter for the reproduction and to #17371 for pinpointing it. Rather than hand-roll a deep traversal keyed on property names, this centralizes the fix on the
$$typeofsignal via the serializer the form already relies on, so there is no duplicated traversal to keep in sync.Fixes #16786.
Verification
pnpm test:unit reduceToSerializableFields— new spec covering: top-levelcustomComponentsstripped,rows[i].customComponents.RowLabelstripped, a genuine circular reference held behind aRowLabelelement severed so the reduced state isJSON.stringify-able, nested block rows preserved, and plain field values / row metadata preserved.pnpm --filter=@payloadcms/ui build— typechecks the newFormState -> FormStateWithoutComponentssignature.pnpm lintandpnpm prettier --check packages/ui/src/forms/Form/reduceToSerializableFields.ts packages/ui/src/forms/Form/reduceToSerializableFields.spec.ts.