Skip to content

fix(ui): remove nested row components when reducing form state to serializable fields#17427

Open
dotCooCoo wants to merge 1 commit into
payloadcms:mainfrom
dotCooCoo:fix/reduce-serializable-fields-nested-components
Open

fix(ui): remove nested row components when reducing form state to serializable fields#17427
dotCooCoo wants to merge 1 commit into
payloadcms:mainfrom
dotCooCoo:fix/reduce-serializable-fields-nested-components

Conversation

@dotCooCoo

Copy link
Copy Markdown

What & why

On the SEO tab, opening a document that has an array (or blocks) field with a custom RowLabel component and a populated relationship row throws when the preview/generate fields fire their request:

TypeError: Converting circular structure to JSON
    --> starting at object with constructor 'BasePayload'
    |   property 'db' -> object with constructor 'Object'
    --- property 'payload' closes the circle

Root cause

Form state is a flat { [path]: FieldState } map, and it carries React nodes at two depths:

  • FieldState.customComponentsField / Label / Error / Description / …
  • FieldState.rows[i].customComponents.RowLabel — the per-row label component

reduceToSerializableFields shallow-copied each field and deleted only the top-level customComponents and validate keys. It never descended into rows[i], so a rendered RowLabel element survived into the POST body sent to /api/plugin-seo/generate-url (and generate-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 -> …), and JSON.stringify on 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 reduceToSerializableFields to deepCopyObjectSimpleWithoutReactComponents — the same helper packages/ui/src/forms/Form/index.tsx already uses to serialize contextRef.current.fields before sending form state to the server.

That helper identifies React elements by their $$typeof symbol and returns undefined for them at the top of each recursive call, before it visits their props. This means:

  • Component trees are pruned at any depth — both the field-level customComponents and the per-row rows[i].customComponents.RowLabel — so the flat map no longer smuggles a rendered tree onto the wire.
  • The circular reference is severed, not traversed: because the element is discarded before its props are read, the BasePayload cycle behind it is never reached.
  • Everything a generateURL / generateTitle implementation actually reads is preserved — field values, initialValue, row metadata (id / blockType / collapsed), filterOptions, valid, error state — with Date preserved and BSON ObjectIds normalized to hex, matching the form's own serialization. File values are dropped (excludeFiles: true), the same as the form does.

The change is centralized in the shared util, so every consumer of reduceToSerializableFields is fixed at once. The four plugin-seo field components are untouched, and there are no package.json / README changes.

Prior attempts

This supersedes two earlier attempts that patched the blocklist inside plugin-seo instead of fixing the shared util:

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 $$typeof signal 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-level customComponents stripped, rows[i].customComponents.RowLabel stripped, a genuine circular reference held behind a RowLabel element severed so the reduced state is JSON.stringify-able, nested block rows preserved, and plain field values / row metadata preserved.
  • pnpm --filter=@payloadcms/ui build — typechecks the new FormState -> FormStateWithoutComponents signature.
  • pnpm lint and pnpm prettier --check packages/ui/src/forms/Form/reduceToSerializableFields.ts packages/ui/src/forms/Form/reduceToSerializableFields.spec.ts.

…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-seo & ui: PreviewField throws "circular structure to JSON"

1 participant