Skip to content

fix(discussion): stop comments jumping around when you vote - #1341

Merged
NiallJoeMaher merged 1 commit into
developfrom
fix/comment-reorder-on-vote
Aug 12, 2026
Merged

fix(discussion): stop comments jumping around when you vote#1341
NiallJoeMaher merged 1 commit into
developfrom
fix/comment-reorder-on-vote

Conversation

@NiallJoeMaher

Copy link
Copy Markdown
Contributor

What

Liking a comment made it jump up the thread under your cursor. Voting refetched the discussion, and "Top" re-sorts by score, so the comment you just liked was immediately re-ranked mid-read.

Ordering by votes is still the point — it just shouldn't happen while someone is reading. So:

  • A successful vote no longer refetches. VoteControl already updates optimistically, so the count responds instantly and the thread never reflows.
  • Each comment's sort score is frozen the first time it's seen, so a later refetch (posting a comment, window refocus) can't reshuffle a thread you're part-way through. Re-picking the sort drops the snapshot, so the current ranking is one click away, and a fresh load ranks from scratch as before.

The trade-off is deliberate and commented: a tab left open for hours keeps the ranking it loaded with.

Two things that fell out of it

The global in-flight guard is gone. voteStatus === "pending" blocked voting on every comment while any one vote was in flight, and it swallowed the click after VoteControl had already toggled itself — leaving the UI showing a vote that was never sent.

So the vote mutation had to become race-safe. With clicks no longer serialised, discussion.vote's SELECT-then-INSERT could race itself: two overlapping requests both see "no vote" and both insert, tripping comment_votes_comment_id_user_id_key (a 500), or the delete variant no-ops and leaves a vote the UI doesn't show. It's now a single delete-by-key or insert … onConflictDoUpdate, so whichever request lands last simply wins. The count triggers are unaffected — a same-value update is a no-op for tr_comment_vote_counts.

Failed votes still resync. The refetch now completes before the controls are remounted; bumping the remount key first would reseed them from the pre-vote cache and strand earlier successful votes showing their old counts.

Verified locally

  • Vote → order unchanged; post a comment (a real refetch with fresh scores) → the existing comments hold their exact order; reload → the newly top-ranked comment moves to first.
  • 6 concurrent votes from one user on one comment: all 200, exactly one vote row, count of 1.
  • Full toggle cycle up → up → down → null → null leaves counts back at 0.
  • npm run lint, npm run prettier, npm run test:unit (118 passing), npm run build.

Voting refetched the thread, and "Top" re-sorts by score, so liking a
comment yanked it up the page mid-read.

A successful vote no longer refetches — VoteControl already updates
optimistically — and each comment's sort score is now frozen the first
time it is seen, so later data refreshes cannot reshuffle a thread
somebody is reading. Ordering still updates, just on the next load, or
immediately if the reader re-picks the sort.

Two things fall out of that:

- The global "a vote is in flight" guard is gone. It blocked votes on
  every other comment while one was pending, and swallowed the click
  after VoteControl had already toggled itself, leaving the UI showing a
  vote that was never sent.
- With clicks no longer serialised, the vote mutation's read-then-write
  could race itself: two overlapping requests both saw "no vote" and both
  inserted, tripping comment_votes_comment_id_user_id_key. It is now a
  single delete-by-key or upsert, so whichever request lands last wins.

Failed votes still resync: the refetch now completes before the controls
are remounted, otherwise they would reseed from the pre-vote cache and
strand earlier successful votes showing their old counts.
@NiallJoeMaher
NiallJoeMaher requested a review from a team as a code owner August 12, 2026 06:50
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
codu Ready Ready Preview Aug 12, 2026 6:52am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The PR changes discussion voting to use atomic server writes and client-side error recovery. It also stabilizes top-score ordering by snapshotting scores and applying creation-time tie-breaking.

Changes

Discussion voting and ordering

Layer / File(s) Summary
Atomic vote persistence
server/api/router/discussion.ts
Vote removal uses a direct delete. Vote creation and updates use an atomic upsert keyed by commentId and userId.
Client vote recovery
components/Discussion/DiscussionArea.tsx
Vote settlements no longer refetch by default. Failed votes show an error, refetch discussion data, and remount VoteControl instances. The pending-vote guard is removed.
Stable top-score ordering
components/Discussion/DiscussionArea.tsx
Top sorting preserves score snapshots across score-only updates, captures new comments, resets on sort changes, and uses creation time for ties.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant VoteControl
  participant DiscussionArea
  participant DiscussionAPI
  participant VoteDatabase
  VoteControl->>DiscussionArea: submit vote
  DiscussionArea->>DiscussionAPI: invoke vote mutation
  DiscussionAPI->>VoteDatabase: delete or upsert vote
  VoteDatabase-->>DiscussionAPI: return mutation result
  DiscussionAPI-->>DiscussionArea: return success or error
  alt vote error
    DiscussionArea->>DiscussionAPI: refetch discussion
    DiscussionArea->>VoteControl: increment reset key
    VoteControl-->>DiscussionArea: remount with server state
  end
Loading

Poem

I’m a rabbit, hopping through the score,
Votes settle cleanly, errors restore.
Atomic paws write votes in place,
Top comments keep a steady race.
Tie by time, then back to the floor—
Carrots for every stable score!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: preventing discussion comments from jumping when users vote.
Description check ✅ Passed The description gives detailed change rationale, implementation details, trade-offs, failure handling, and local verification results.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/comment-reorder-on-vote

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (2)
components/Discussion/DiscussionArea.tsx (2)

190-212: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

sortDiscussions reads sortScores but is not memoized per subtree.

generateDiscussions calls sortDiscussions once per node and again for each child list at line 481. Each call copies and sorts the array. For a deep thread this repeats work on every render, including every keystroke in an open editor, because showCommentBoxId, editContent, and voteResetKey all live in this component.

The current thread sizes probably make this acceptable. If threads grow, memoize the sorted tree once per discussions/sortOrder/sortScores change instead of sorting during render.

🤖 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 `@components/Discussion/DiscussionArea.tsx` around lines 190 - 212, Memoize the
sorted discussion tree so sorting is recomputed only when discussions,
sortOrder, or sortScores changes. Update the sortDiscussions/generateDiscussions
flow to reuse the memoized result for the root and child lists rather than
copying and sorting each subtree during every render.

169-188: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the snapshot reset out of the render phase.

sortScores mutates frozenSortScores.current and assigns frozenForSort.current while rendering. React 19 can start a render, discard it, and render again. If a render that clears and re-captures the snapshot is discarded, the captured scores come from a tree version that React throws away. StrictMode double-invokes the memo, which also runs the reset branch during render.

The capture itself is idempotent, so this is unlikely to produce a visible defect today. It is still fragile under concurrent rendering.

A safer shape keys the snapshot by sortOrder and avoids the reset branch entirely.

♻️ Proposed refactor: key the snapshot by sort order
-  const frozenSortScores = useRef(new Map<string, number>());
-  const frozenForSort = useRef<SortOrder>(sortOrder);
+  const frozenSortScores = useRef(new Map<SortOrder, Map<string, number>>());
   const sortScores = useMemo(() => {
-    // Re-picking a sort is a deliberate "show me the current ranking", so let
-    // that re-rank from live scores. Passive vote traffic must not.
-    if (frozenForSort.current !== sortOrder) {
-      frozenForSort.current = sortOrder;
-      frozenSortScores.current.clear();
-    }
-    const captured = frozenSortScores.current;
+    // Re-picking a sort is a deliberate "show me the current ranking", so it
+    // starts a fresh snapshot. Passive vote traffic reuses the existing one.
+    let captured = frozenSortScores.current.get(sortOrder);
+    if (!captured) {
+      captured = new Map<string, number>();
+      frozenSortScores.current.set(sortOrder, captured);
+    }

Note that this variant keeps a snapshot per sort order, so re-picking a previously used sort reuses its old snapshot. If you want re-picking to always re-rank, keep a monotonic sort-selection counter in state and clear the map in an effect instead.

🤖 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 `@components/Discussion/DiscussionArea.tsx` around lines 169 - 188, Refactor
sortScores to avoid mutating frozenForSort.current or frozenSortScores.current
during render: key the stored snapshots by sortOrder and reuse the corresponding
snapshot without a reset branch. Update the capture logic to populate only that
sort order’s snapshot, preserving frozen scores across passive vote updates and
allowing React to discard or replay renders safely.
🤖 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.

Nitpick comments:
In `@components/Discussion/DiscussionArea.tsx`:
- Around line 190-212: Memoize the sorted discussion tree so sorting is
recomputed only when discussions, sortOrder, or sortScores changes. Update the
sortDiscussions/generateDiscussions flow to reuse the memoized result for the
root and child lists rather than copying and sorting each subtree during every
render.
- Around line 169-188: Refactor sortScores to avoid mutating
frozenForSort.current or frozenSortScores.current during render: key the stored
snapshots by sortOrder and reuse the corresponding snapshot without a reset
branch. Update the capture logic to populate only that sort order’s snapshot,
preserving frozen scores across passive vote updates and allowing React to
discard or replay renders safely.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a7f4140-c54a-4c97-a53a-dfc22bb2e71c

📥 Commits

Reviewing files that changed from the base of the PR and between 2ac41e9 and bde9815.

📒 Files selected for processing (2)
  • components/Discussion/DiscussionArea.tsx
  • server/api/router/discussion.ts

@github-actions

Copy link
Copy Markdown

Uh oh! @vercel[bot], the image you shared is missing helpful alt text. Check #1341 (comment).

Alt text is an invisible description that helps screen readers describe images to blind or low-vision users. If you are using markdown to display images, add your alt text inside the brackets of the markdown image.

Learn more about alt text at Basic writing and formatting syntax: images on GitHub Docs.

@NiallJoeMaher
NiallJoeMaher merged commit 33771d3 into develop Aug 12, 2026
6 of 8 checks passed
@NiallJoeMaher
NiallJoeMaher deleted the fix/comment-reorder-on-vote branch August 12, 2026 07:03
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.

1 participant