Skip to content

Fixed threaded comments handling of hidden/deleted replies#28024

Closed
kevinansfield wants to merge 1 commit into
TryGhost:mainfrom
kevinansfield:codex/comments-thread-structure-sql
Closed

Fixed threaded comments handling of hidden/deleted replies#28024
kevinansfield wants to merge 1 commit into
TryGhost:mainfrom
kevinansfield:codex/comments-thread-structure-sql

Conversation

@kevinansfield
Copy link
Copy Markdown
Member

@kevinansfield kevinansfield commented May 21, 2026

What changed

  • Added a server-side path-based recursive CTE for resolving displayable comment IDs.
  • Scoped post comment reads by post_id inside the CTE so tombstone path resolution stays constrained to the post being browsed.
  • Top-level comment queries and reply queries now include hidden/deleted tombstones when they are needed to preserve the path to a visible descendant.
  • Embedded reply loading scopes the CTE to the current batch of parent comment IDs so it does not derive reply tombstones from unrelated threads.
  • Kept count.replies and count.direct_replies as visible reply counts rather than structural tombstone counts.
  • Preserved local deleted reply tombstones only while loaded descendants still depend on them, and pruned ancestor tombstones after sequential local deletes.
  • Bumped @tryghost/comments-ui to 1.5.3.
  • Added public and admin API coverage for nested hidden/deleted ancestors, reply endpoints, visible-count semantics, pagination with tombstone paths, and dead hidden/deleted branches that should not be returned.
  • Updated the Comments UI thread graph and deletion unit coverage for missing parents, server-provided tombstones, direct-reply count handling, and local tombstone pruning.

Why

Threaded comments need real server-provided tombstones for hidden/deleted ancestors so visible replies render at the correct nesting level. Doing this in SQL keeps the API response authoritative, avoids client-side tombstone inference, and prevents returning hidden/deleted leaf replies that do not preserve any visible thread structure.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 21, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR updates the Ghost comments system to preserve reply thread structure by returning deleted/hidden ancestor replies as tombstones when they have visible descendants. The backend implements a recursive SQL CTE (getVisibleReplyAncestorsQuery) to traverse in_reply_to_id chains, filtering visible ancestors by status. The comment model's applyCustomQuery, applyRepliesWithRelatedOption, and findPage methods are updated to scope reply filtering to loaded parent IDs and include ancestors matching the recursive set. Frontend and E2E tests are added to verify correct threading behavior through placeholder ancestors and tombstone visibility rules.

Possibly related PRs

  • TryGhost/Ghost#27847: Updated buildThreadedReplies threading edge-case tests are directly related to the threaded-comments implementation that originally exported this function for use in Comments UI nested reply trees.
  • TryGhost/Ghost#27962: Both PRs update apps/comments-ui thread graph logic and tests around buildThreadedReplies, extending the threading model with ancestor and sibling metadata handling.

Suggested reviewers

  • rob-ghost
  • jonatansberg
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: fixing threaded comments handling of hidden/deleted replies, which is the core objective across all file changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description clearly explains the changes made (server-side recursive CTE for comment display, hidden/deleted tombstones preservation, reply endpoint updates) and their rationale (maintaining correct nesting for threaded comments).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@kevinansfield kevinansfield force-pushed the codex/comments-thread-structure-sql branch from 5d9b222 to fa8921c Compare May 21, 2026 09:20
@kevinansfield kevinansfield changed the title [codex] Fixed threaded comment structure filtering [codex] Fixed threaded comment fallback rendering May 21, 2026
@kevinansfield kevinansfield changed the title [codex] Fixed threaded comment fallback rendering Fixed threaded comments handling of hidden/deleted replies May 21, 2026
@kevinansfield kevinansfield force-pushed the codex/comments-thread-structure-sql branch 3 times, most recently from 2a83a9f to 5f345c2 Compare May 24, 2026 09:50
@kevinansfield kevinansfield marked this pull request as ready for review May 24, 2026 10:37
Copy link
Copy Markdown
Member

@jonatansberg jonatansberg left a comment

Choose a reason for hiding this comment

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

I added a comment for something we need to double check before this goes in.

As an aside, I'm not loving the recursive CTE. It feels like there should be simpler ways to express this. I wonder if we should just fetch all of the comments and then apply the hiding in the application layer? Including dropping hidden comments without decendants.

Alternatively, we could also use conditionals within the SQL query to return tombstones for the comments that should be hidden. Trimming hidden comments without descendants might still need to happen in the application layer though or we likely end up back in CTE land 🤔

emptyComment: 'The body of a comment cannot be empty'
};

function getVisibleReplyAncestorsQuery(excludedStatuses) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This might be a lack of caffeine, but, don't we want to get all the ancestors regardless of their status? If we're only getting the visible ones, how is that any different from the default behavior?

Copy link
Copy Markdown
Member Author

@kevinansfield kevinansfield May 26, 2026

Choose a reason for hiding this comment

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

The default query excludes all hidden/deleted replies to a top-level comment, even if they have visible replies deeper in the graph which is what breaks the nested thread display.

This keeps hidden/deleted ancestors of visible replies returned in the query so we can display the full thread graph.

Copy link
Copy Markdown
Member Author

Yeah, I'm not a huge a fan of the CTE either. With culling at the application layer the main thing I was running into was our pagination/counts for number of replies.

Copy link
Copy Markdown
Member

jonatansberg commented May 26, 2026

Ugh… Yeah. We could maybe do all of it in one go with a HAVING or CTE query and select all rows that either have a "visible replies count" > 0 or a visible status. That would let us do everything in the DB.

@kevinansfield kevinansfield force-pushed the codex/comments-thread-structure-sql branch from 5f345c2 to d6ae59b Compare May 26, 2026 08:40
@nx-cloud
Copy link
Copy Markdown

nx-cloud Bot commented May 26, 2026

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit e8b0c91

Command Status Duration Result
nx run ghost:test:ci:integration ✅ Succeeded 1m 42s View ↗
nx run ghost:test:ci:e2e ✅ Succeeded 6m 17s View ↗
nx run ghost:test:ci:legacy ✅ Succeeded 3m 5s View ↗
nx build @tryghost/portal ✅ Succeeded <1s View ↗
nx build @tryghost/signup-form ✅ Succeeded <1s View ↗
nx build @tryghost/activitypub ✅ Succeeded 2s View ↗
nx build @tryghost/comments-ui ✅ Succeeded 1s View ↗
nx build @tryghost/sodo-search ✅ Succeeded <1s View ↗
Additional runs (8) ✅ Succeeded ... View ↗

☁️ Nx Cloud last updated this comment at 2026-05-26 14:33:26 UTC

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ghost/core/core/server/models/comment.js`:
- Around line 10-31: The recursive CTE in getVisibleReplyAncestorsQuery walks
the entire comments table; change it to seed the recursion from the current
reply id set and only recurse from those ancestors. Update
getVisibleReplyAncestorsQuery to accept the current reply IDs (or add a second
parameter like replyIds) and use those IDs as the initial SELECT (e.g., SELECT
in_reply_to_id FROM comments WHERE id IN (...) AND in_reply_to_id IS NOT NULL
AND status NOT IN (placeholders)), then recurse only from that seed (keep the
excludedStatuses placeholders), and ensure the final SELECT returns only
ancestors derived from that seeded set; apply the same scoping change to the
related CTE usage at the other occurrence noted (lines ~205-211).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bf163855-3adf-4ddf-b45f-083764829de6

📥 Commits

Reviewing files that changed from the base of the PR and between 5f345c2 and d6ae59b.

⛔ Files ignored due to path filters (1)
  • ghost/core/test/e2e-api/members-comments/__snapshots__/comments.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (4)
  • apps/comments-ui/test/unit/utils/thread-graph.test.ts
  • ghost/core/core/server/models/comment.js
  • ghost/core/test/e2e-api/admin/comments.test.js
  • ghost/core/test/e2e-api/members-comments/comments.test.js

Comment thread ghost/core/core/server/models/comment.js Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ghost/core/core/server/models/comment.js`:
- Around line 261-263: The call to applyRepliesWithRelatedOption for
non-findPage paths omits parentIds, which causes getVisibleReplyAncestorsQuery
to receive an empty parentIds set and skip the ancestor-preservation branch; fix
by passing the relevant parentIds into applyRepliesWithRelatedOption (so
getVisibleReplyAncestorsQuery can evaluate ancestor preservation) — update the
call site that invokes applyRepliesWithRelatedOption(methodName !== 'findPage')
to supply the parentIds (or derive them from options) and adjust
applyRepliesWithRelatedOption signature if needed so
getVisibleReplyAncestorsQuery sees non-empty parentIds and preserves
hidden/deleted ancestors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 263b94a4-06b5-4713-96fa-b335cdc80c8b

📥 Commits

Reviewing files that changed from the base of the PR and between d6ae59b and b286294.

⛔ Files ignored due to path filters (1)
  • ghost/core/test/e2e-api/members-comments/__snapshots__/comments.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (4)
  • apps/comments-ui/test/unit/utils/thread-graph.test.ts
  • ghost/core/core/server/models/comment.js
  • ghost/core/test/e2e-api/admin/comments.test.js
  • ghost/core/test/e2e-api/members-comments/comments.test.js

Comment thread ghost/core/core/server/models/comment.js
@kevinansfield kevinansfield force-pushed the codex/comments-thread-structure-sql branch from 97ec2af to d7a938e Compare May 26, 2026 11:25
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
ghost/core/core/server/models/comment.js (1)

275-277: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Pass parent scope into non-findPage reply filtering.

applyRepliesWithRelatedOption is called without parentIds, so getVisibleReplyAncestorsQuery falls back to an empty set (Line 11-Line 13). That disables ancestor tombstone preservation on non-findPage paths that eager-load replies, creating inconsistent behavior versus findPage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/core/server/models/comment.js` around lines 275 - 277, The call to
applyRepliesWithRelatedOption inside the non-'findPage' branch omits parentIds,
causing getVisibleReplyAncestorsQuery to receive an empty set and drop ancestor
tombstone preservation; update the invocation in that branch to pass the
parentIds value (i.e. call applyRepliesWithRelatedOption with the same parentIds
argument used elsewhere) so getVisibleReplyAncestorsQuery receives the correct
parentIds and preserves ancestor tombstones when eager-loading replies.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@ghost/core/core/server/models/comment.js`:
- Around line 275-277: The call to applyRepliesWithRelatedOption inside the
non-'findPage' branch omits parentIds, causing getVisibleReplyAncestorsQuery to
receive an empty set and drop ancestor tombstone preservation; update the
invocation in that branch to pass the parentIds value (i.e. call
applyRepliesWithRelatedOption with the same parentIds argument used elsewhere)
so getVisibleReplyAncestorsQuery receives the correct parentIds and preserves
ancestor tombstones when eager-loading replies.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0b0f30e5-f38f-4426-8da7-5a327f95f100

📥 Commits

Reviewing files that changed from the base of the PR and between 97ec2af and d7a938e.

⛔ Files ignored due to path filters (1)
  • ghost/core/test/e2e-api/members-comments/__snapshots__/comments.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (4)
  • apps/comments-ui/test/unit/utils/thread-graph.test.ts
  • ghost/core/core/server/models/comment.js
  • ghost/core/test/e2e-api/admin/comments.test.js
  • ghost/core/test/e2e-api/members-comments/comments.test.js
✅ Files skipped from review due to trivial changes (1)
  • apps/comments-ui/test/unit/utils/thread-graph.test.ts

@kevinansfield kevinansfield force-pushed the codex/comments-thread-structure-sql branch from d7a938e to e8b0c91 Compare May 26, 2026 14:22
@kevinansfield kevinansfield force-pushed the codex/comments-thread-structure-sql branch 3 times, most recently from a22e205 to 32aceca Compare May 26, 2026 21:19
ref https://linear.app/ghost/issue/BER-3677/hidden-comments-collapse-thread-nesting-incorrectly

Comment APIs need to return hidden/deleted tombstones when they preserve the path to visible descendants. This uses a post-scoped path-based recursive SQL query for displayable comment IDs, keeps reply counts tied to visible comments, preserves local deleted reply tombstones while descendants are loaded, and bumps Comments UI for release.
@kevinansfield kevinansfield force-pushed the codex/comments-thread-structure-sql branch 2 times, most recently from 32aceca to 28f3963 Compare May 26, 2026 21:44
@kevinansfield
Copy link
Copy Markdown
Member Author

Merged as part of c0aa1ce

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.

2 participants