plugin-nested-docs: getParents has no cycle detection — stack overflow on cyclic parent chain
Summary
`@payloadcms/plugin-nested-docs` (verified at v3.82.1) walks the parent chain via a recursive utility `getParents` that has zero cycle detection. If a document's `parentFieldSlug` chain forms a cycle (e.g. doc X has X as parent, or A→B→A), the recursion runs until Node's stack limit and crashes the request.
Source
`node_modules/@payloadcms/plugin-nested-docs/dist/utilities/getParents.js` (also visible at `packages/plugin-nested-docs/src/utilities/getParents.ts` in this monorepo):
```ts export const getParents = async (req, pluginConfig, collection, doc, docs = []) => { const parentSlug = pluginConfig?.parentFieldSlug || 'parent'; const parent = doc[parentSlug]; let retrievedParent = null; if (parent) { if (typeof parent === 'string' || typeof parent === 'number') { retrievedParent = await req.payload.findByID({ id: parent, collection: collection.slug, depth: 0, disableErrors: true, req }); } if (typeof parent === 'object') { retrievedParent = parent; } if (retrievedParent) { if (retrievedParent[parentSlug]) { return getParents(req, pluginConfig, collection, retrievedParent, [retrievedParent, ...docs]); } return [retrievedParent, ...docs]; } } return docs; }; ```
This is called from `hooks/populateBreadcrumbsBeforeChange.js` on every save — if a cycle exists in the data, every save (or any code path that triggers the breadcrumb populator) recurses forever.
Repro
- Configure the plugin with a self-referential collection: ```ts nestedDocsPlugin({ collections: ['pages'], parentFieldSlug: 'parentPage', // ... }) ```
- Insert a document whose `parentPage` references itself (e.g. via a direct DB write that bypasses Payload's hooks, or a race condition during save).
- Trigger a save on that document, or any other code path that invokes `populateBreadcrumbs`.
- Observe: stack overflow, request crashes.
Why we noticed
We rely on a custom field-level `validate` to reject cycles on the `parentPage` relationship before the plugin's `beforeChange` ever runs (see our local validator). When auditing whether we could remove our local cycle check, we discovered the plugin doesn't provide one — so our local validator is load-bearing.
Suggested fix
Track visited IDs in a `Set` and bail with a clear error when a cycle is detected:
```ts export const getParents = async ( req, pluginConfig, collection, doc, docs = [], visited = new Set(), ) => { const parentSlug = pluginConfig?.parentFieldSlug || 'parent'; const parent = doc[parentSlug]; if (!parent) return docs;
if (doc?.id != null) { if (visited.has(String(doc.id))) { // Cycle — return what we have rather than recursing forever. // Optional: req.payload.logger.warn(`nested-docs: cycle detected at ${doc.id}`) return docs; } visited.add(String(doc.id)); }
// ...rest unchanged, threading `visited` into the recursive call... }; ```
Alternative: cap recursion depth (e.g., 16) and throw a clear error so callers see a deterministic failure mode rather than an opaque stack overflow.
Either way, documenting the invariant ("callers must enforce no cycles in the parent chain") would also help integrators decide whether to keep their own cycle protection.
Environment
- `payload`: 3.82.1
- `@payloadcms/plugin-nested-docs`: 3.82.1
- Node 22.x, Postgres adapter
Happy to send a PR if helpful — let me know which approach (in-utility cycle detection vs. depth cap vs. doc-only) you'd prefer.
Source: payloadcms/payload