Skip to content

fix(pages): prevent circular parent references#61

Merged
jhb-dev merged 9 commits intomainfrom
copilot/fix-circular-parent-references
Apr 2, 2026
Merged

fix(pages): prevent circular parent references#61
jhb-dev merged 9 commits intomainfrom
copilot/fix-circular-parent-references

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 15, 2026

  • Understand the codebase structure and hooks pattern
  • Create preventCircularParentReference beforeChange hook
  • Register the hook in PageCollectionConfig.ts
  • Add tests for direct self-reference, 2-node cycle, and deep cycle cases
  • Run tests to verify implementation (55/55 pass)
  • Run pnpm lint and fix all issues
  • Move preventCircularParentReference to run after user beforeChange hooks (per Jay's review)
  • Fix CI format check failure — apply Prettier formatting after eslint --fix
  • Revert .gitignore corruption and remove package-lock.json entry
  • Skip ancestor walk on update when parent value is unchanged
Original prompt

This section details on the original issue you should resolve

<issue_title>fix: prevent circular parent references</issue_title>
<issue_description>## Problem

The pages plugin has no protection against circular parent references. Setting a document as its own parent, or creating a cycle (A -> B -> C -> A), would cause infinite loops during breadcrumb/path generation as the plugin walks up the ancestor chain.

Proposed Solution

Add a beforeChange hook that validates the parent field by walking up the ancestor chain and detecting cycles using a visited-nodes Set.

Implementation sketch

Inspired by the Payload hierarchy PR which implements this in collectionBeforeChange.ts:

async function validateNoCircularReference({ id, parentId, collection, req }) {
  if (id === parentId) {
    throw new ValidationError({ errors: [{ message: 'A document cannot be its own parent', path: 'parent' }] })
  }

  const visited = new Set([id])
  let currentParentId = parentId

  while (currentParentId) {
    if (visited.has(currentParentId)) {
      throw new ValidationError({ errors: [{ message: 'Circular parent reference detected', path: 'parent' }] })
    }
    visited.add(currentParentId)

    const parent = await req.payload.findByID({ collection, id: currentParentId, depth: 0, select: { parent: true } })
    currentParentId = parent?.parent
  }
}

Cases to cover

  • Direct self-reference: doc.parent = doc.id
  • Two-node cycle: A -> B -> A
  • Deep cycle: A -> B -> C -> A

Testing

Add tests for this.</issue_description>

Comments on the Issue (you are @copilot in this section)


📍 Connect Copilot coding agent with Jira, Azure Boards or Linear to delegate work to Copilot in one click without leaving your project management tool.

Add a `beforeChange` hook that validates the parent field by walking up
the ancestor chain and detecting cycles using a visited-nodes Set.

Handles:
- Direct self-reference (doc.parent = doc.id)
- Two-node cycles (A -> B -> A)
- Deep cycles (A -> B -> C -> A)

Tests added for all three cases plus a valid parent scenario.

Co-authored-by: jhb-dev <20878653+jhb-dev@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix circular parent references in pages plugin fix: prevent circular parent references in pages plugin Mar 15, 2026
Copilot AI requested a review from jhb-dev March 15, 2026 16:06
...(incomingCollectionConfig.hooks?.afterChange || []),
],
beforeChange: [
preventCircularParentReference,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good for the manually tested admin/API path, but I think the placement here is still fragile. Because this validator runs before consumer beforeChange hooks, a later hook can still modify data.parent and introduce a cycle after this check has already passed. Would it make sense to enforce this at the final authoritative boundary for the resolved parent value instead of relying on early hook order?

Copy link
Copy Markdown
Contributor

@srjaykikani srjaykikani left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together. I manually tested the localized dev app flow for this change:

  • created a root page
  • created a child under that root
  • created a grandchild under that child
  • edited the child and tried to set its parent to the grandchild

That save was rejected with a validation error, and the persisted parent stayed unchanged, so the tested path is working as expected.

One structural concern I still have: the new cycle check is added as the first beforeChange hook in createPageCollectionConfig. Since consumer-provided beforeChange hooks run after that, a later hook can still rewrite data.parent after this validator has already passed. So this fixes the path I tested, but it does not fully guarantee the invariant at the final authoritative save boundary.

Also, pnpm lint is currently failing only on pages/src/hooks/preventCircularParentReference.ts in this PR.

Overall, the behavior looks correct for the manually tested case, but I think the hook-order piece is still worth tightening so the fix addresses the root cause rather than only the current path.

@jhb-dev
Copy link
Copy Markdown
Contributor

jhb-dev commented Mar 24, 2026

@copilot run pnpm lint and fix any issues. In a separate commit please change the order of the before change hooks to address Jays comments.

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 24, 2026

@jhb-dev The model claude-sonnet-4.6 is not available for your account. This can happen if the model was disabled by your organization's policy or if your Copilot plan doesn't include access to it.

You can try again without specifying a model (just @copilot) to use the default, or choose a different model from the model picker.

If you want to contact GitHub about this error, please mention the following identifier so they can better serve you: f8845fbe-2c2a-415c-8a06-df265727e361

5 similar comments
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 24, 2026

@jhb-dev The model claude-sonnet-4.6 is not available for your account. This can happen if the model was disabled by your organization's policy or if your Copilot plan doesn't include access to it.

You can try again without specifying a model (just @copilot) to use the default, or choose a different model from the model picker.

If you want to contact GitHub about this error, please mention the following identifier so they can better serve you: f8845fbe-2c2a-415c-8a06-df265727e361

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 24, 2026

@jhb-dev The model claude-sonnet-4.6 is not available for your account. This can happen if the model was disabled by your organization's policy or if your Copilot plan doesn't include access to it.

You can try again without specifying a model (just @copilot) to use the default, or choose a different model from the model picker.

If you want to contact GitHub about this error, please mention the following identifier so they can better serve you: f8845fbe-2c2a-415c-8a06-df265727e361

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 24, 2026

@jhb-dev The model claude-sonnet-4.6 is not available for your account. This can happen if the model was disabled by your organization's policy or if your Copilot plan doesn't include access to it.

You can try again without specifying a model (just @copilot) to use the default, or choose a different model from the model picker.

If you want to contact GitHub about this error, please mention the following identifier so they can better serve you: f8845fbe-2c2a-415c-8a06-df265727e361

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 24, 2026

@jhb-dev The model claude-sonnet-4.6 is not available for your account. This can happen if the model was disabled by your organization's policy or if your Copilot plan doesn't include access to it.

You can try again without specifying a model (just @copilot) to use the default, or choose a different model from the model picker.

If you want to contact GitHub about this error, please mention the following identifier so they can better serve you: f8845fbe-2c2a-415c-8a06-df265727e361

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 24, 2026

@jhb-dev The model claude-sonnet-4.6 is not available for your account. This can happen if the model was disabled by your organization's policy or if your Copilot plan doesn't include access to it.

You can try again without specifying a model (just @copilot) to use the default, or choose a different model from the model picker.

If you want to contact GitHub about this error, please mention the following identifier so they can better serve you: f8845fbe-2c2a-415c-8a06-df265727e361

@jhb-dev
Copy link
Copy Markdown
Contributor

jhb-dev commented Mar 24, 2026

@copilot run pnpm lint and fix any issues. In a separate commit please change the order of the before change hooks to address Jays comments.

Copilot AI and others added 2 commits March 24, 2026 16:11
Ensures the cycle check validates the final resolved parent value,
after any consumer beforeChange hooks have had a chance to modify data.

Co-authored-by: jhb-dev <20878653+jhb-dev@users.noreply.github.com>
Agent-Logs-Url: https://github.com/jhb-software/payload-plugins/sessions/1cb9190d-1d15-4f4f-8ce2-77d993183e14
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 24, 2026

@copilot run pnpm lint and fix any issues. In a separate commit please change the order of the before change hooks to address Jays comments.

Done in two separate commits:

  1. 777e84c — lint fixes: removed the unused pagesPluginConfig variable, fixed import ordering, curly braces, object key ordering, and union type ordering flagged by eslint. pnpm lint now exits clean.

  2. 9b6a286 — hook order change: moved preventCircularParentReference to run after user-defined beforeChange hooks (matching the pattern in beforeDelete/beforeRead), so the cycle check validates the final parent value after any consumer hook has had a chance to modify it.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • telemetry.payloadcms.com
    • Triggering command: /opt/hostedtoolcache/node/24.14.0/x64/bin/node /opt/hostedtoolcache/node/24.14.0/x64/bin/node --conditions node --conditions development --experimental-import-meta-resolve --require /home/REDACTED/work/payload-plugins/payload-plugins/pages/node_modules/.pnpm/vitest@4.0.18_@types&#43;node@25.2.3_sass@1.77.4_tsx@4.21.0/node_modules/vitest/suppress-warnings.cjs /home/REDACTED/work/payload-plugins/payload-plugins/pages/node_modules/.pnpm/vitest@4.0.18_@types&#43;node@25.2.3_sass@1.77.4_tsx@4.21.0/node_modules/vitest/dist/workers/forks.js (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@jhb-dev
Copy link
Copy Markdown
Contributor

jhb-dev commented Mar 24, 2026

@copilot, are you able to access the ci runs of this PR to see if all were successful? If not please fix.

Copilot AI and others added 3 commits March 24, 2026 17:43
Fixes CI format check that failed because eslint --fix added
single-line curly braces that Prettier reformats to multi-line.

Co-authored-by: jhb-dev <20878653+jhb-dev@users.noreply.github.com>
Agent-Logs-Url: https://github.com/jhb-software/payload-plugins/sessions/7b075ce6-3aec-4ce4-a145-299ec9992d7f
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 24, 2026

@copilot, are you able to access the ci runs of this PR to see if all were successful? If not please fix.

Yes — the CI format job was failing. The eslint --fix had added single-line {return data} curly brace forms that Prettier reformats to multi-line. Fixed in 61646ae by running Prettier on the file.

I also accidentally committed a package-lock.json (generated by npm install when I was looking for the eslint binary) — removed it in 141d198 and added it to pages/.gitignore.

@jhb-dev
Copy link
Copy Markdown
Contributor

jhb-dev commented Mar 24, 2026

@copilot revert the gitignore change. Also, will the parent find calls also be executed if the parent has not changed? E. g. the parent in the update is the same as before?

…nchanged

- Restore pages/.gitignore: fix corrupted /dist line (was merged with
  package-lock.json due to missing trailing newline), remove the
  package-lock.json entry added in the previous session
- Skip the ancestor-chain findByID walk on updates when the parent
  field value has not changed, avoiding unnecessary DB queries

Co-authored-by: jhb-dev <20878653+jhb-dev@users.noreply.github.com>
Agent-Logs-Url: https://github.com/jhb-software/payload-plugins/sessions/64bad0b7-e3af-4159-b233-e7d9e3341c53
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 24, 2026

@copilot revert the gitignore change. Also, will the parent find calls also be executed if the parent has not changed? E. g. the parent in the update is the same as before?

Done in 9527e11:

  1. .gitignore reverted — the previous session had a bug where appending package-lock.json without a trailing newline merged it with /dist into /distpackage-lock.json. That's now fixed — /dist is restored properly and package-lock.json is not in the gitignore.

  2. Parent unchanged short-circuit — yes, the ancestor walk was running unconditionally on updates. Added a check at lines 53–62: on update operations, if the incoming parent ID matches originalDoc's parent ID, we return early without any findByID calls.

@jhb-dev jhb-dev marked this pull request as ready for review April 2, 2026 17:22
@jhb-dev jhb-dev changed the title fix: prevent circular parent references in pages plugin fix(pages): prevent circular parent references in pages plugin Apr 2, 2026
@jhb-dev jhb-dev changed the title fix(pages): prevent circular parent references in pages plugin fix(pages): prevent circular parent references Apr 2, 2026
@jhb-dev jhb-dev merged commit b717741 into main Apr 2, 2026
10 of 11 checks passed
@jhb-dev jhb-dev deleted the copilot/fix-circular-parent-references branch April 2, 2026 18:32
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.

fix: prevent circular parent references

3 participants