Skip to content

Commit d3d7c19

Browse files
committed
🐛 Fixed duplicate replies when replying after opening a comment permalink
ref https://linear.app/ghost/issue/BER-3706 - Replies are returned ordered by created_at, but the client paginated them with an `id:>` cursor; when created_at order diverges from id order (e.g. imported or backdated comments) the cursor re-returned already-loaded replies, which were appended without de-duplication and rendered twice — most visibly after a permalink bulk-loads every reply and the member then clicks Reply. - Switched the bulk reply load to page-based pagination (limit=100&page=N) over the server's stable order, so there is no client-side cursor to diverge and no reliance on limit=all (which Ghost 6 clamps to 100). loadMoreReplies rebuilds the replies array from the fetched set and de-dupes by id, while preserving any just-posted reply the cache has not returned yet. - Removed the unreachable admin reply branch; the only caller always loads replies via the members API.
1 parent 895135b commit d3d7c19

4 files changed

Lines changed: 115 additions & 47 deletions

File tree

apps/comments-ui/src/actions.ts

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -69,48 +69,34 @@ async function setOrder({state, data: {order}, options, api, dispatchAction}: {s
6969
}
7070
}
7171

72-
async function loadMoreReplies({state, api, data: {comment, limit}, isReply}: {state: EditableAppContext, api: GhostApi, data: {comment: Comment, limit?: number | 'all'}, isReply: boolean}): Promise<Partial<EditableAppContext>> {
73-
const fetchReplies = async (afterReplyId: string | undefined, requestLimit: number) => {
74-
if (state.admin && state.adminApi && !isReply) { // we don't want the admin api to load reply data for replying to a reply, so we pass isReply: true
75-
return await state.adminApi.replies({commentId: comment.id, afterReplyId, limit: requestLimit, memberUuid: state.member?.uuid});
76-
} else {
77-
return await api.comments.replies({commentId: comment.id, afterReplyId, limit: requestLimit});
78-
}
79-
};
80-
81-
let afterReplyId: string | undefined = comment.replies && comment.replies.length > 0
82-
? comment.replies[comment.replies.length - 1]?.id
83-
: undefined;
84-
85-
let allComments: Comment[] = [];
86-
87-
if (limit === 'all') {
88-
let hasMore = true;
89-
90-
while (hasMore) {
91-
const data = await fetchReplies(afterReplyId, 100);
92-
allComments.push(...data.comments);
93-
hasMore = !!data.meta?.pagination?.next;
94-
95-
if (data.comments && data.comments.length > 0) {
96-
afterReplyId = data.comments[data.comments.length - 1]?.id;
97-
} else {
98-
// If no comments returned, stop pagination to prevent infinite loop
99-
hasMore = false;
100-
}
72+
async function loadMoreReplies({state, api, data: {comment, limit}}: {state: EditableAppContext, api: GhostApi, data: {comment: Comment, limit?: number | 'all'}}): Promise<Partial<EditableAppContext>> {
73+
const data = await api.comments.replies({commentId: comment.id, limit});
74+
75+
// The freshly fetched set is authoritative for ordering and completeness, so we
76+
// rebuild the replies array from it rather than appending with a cursor (that
77+
// re-fetched already-loaded replies and duplicated them — BER-3706). But a reply
78+
// the member just posted may not be in the (cached) response yet — addReply keeps
79+
// it locally — so preserve any already-loaded replies the server didn't return.
80+
// De-dupe by id so nothing can render twice.
81+
const fetchedReplies: Comment[] = data.comments;
82+
const fetchedReplyIds = new Set<string>(fetchedReplies.map((reply: Comment) => reply.id));
83+
const pendingReplies = comment.replies.filter(reply => !fetchedReplyIds.has(reply.id));
84+
85+
const seenReplyIds = new Set<string>();
86+
const replies: Comment[] = [...fetchedReplies, ...pendingReplies].filter((reply) => {
87+
if (seenReplyIds.has(reply.id)) {
88+
return false;
10189
}
102-
} else {
103-
const data = await fetchReplies(afterReplyId, limit as number || 100);
104-
allComments = data.comments;
105-
}
90+
seenReplyIds.add(reply.id);
91+
return true;
92+
});
10693

107-
// Note: we store the comments from new to old, and show them in reverse order
10894
return {
10995
comments: state.comments.map((c) => {
11096
if (c.id === comment.id) {
11197
return {
11298
...comment,
113-
replies: [...comment.replies, ...allComments]
99+
replies
114100
};
115101
}
116102
return c;
@@ -710,7 +696,7 @@ async function openCommentForm({data: newForm, api, state}: {data: OpenCommentFo
710696

711697
if (comment) {
712698
try {
713-
const newCommentsState = await loadMoreReplies({state, api, data: {comment, limit: 'all'}, isReply: true});
699+
const newCommentsState = await loadMoreReplies({state, api, data: {comment, limit: 'all'}});
714700
otherStateChanges = {...otherStateChanges, ...newCommentsState};
715701
} catch (e) {
716702
// If loading replies fails, continue anyway - the form should still open

apps/comments-ui/src/utils/api.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,19 +169,22 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}: {site
169169

170170
return response;
171171
},
172-
async replies({commentId, afterReplyId, limit}: {commentId: string; afterReplyId?: string; limit?: number | 'all'}) {
172+
async replies({commentId, afterReplyId, limit, page}: {commentId: string; afterReplyId?: string; limit?: number | 'all'; page?: number}) {
173173
if (limit === 'all') {
174+
// Walk the pages instead of seeking with an `id:>` cursor: replies
175+
// are ordered by (created_at ASC, id ASC), and an id cursor breaks
176+
// whenever created_at order diverges from id order (e.g. imported or
177+
// backdated comments), re-fetching already-loaded replies (BER-3706).
178+
// Page-based pagination over that stable order is dialect-agnostic.
174179
const all: Comment[] = [];
175-
let cursor: string | undefined = afterReplyId;
180+
let currentPage = 1;
176181
let hasMore = true;
177182

178183
while (hasMore) {
179-
const data = await this.replies({commentId, afterReplyId: cursor, limit: 100});
184+
const data = await this.replies({commentId, limit: 100, page: currentPage});
180185
all.push(...data.comments);
181186
hasMore = !!data.meta?.pagination?.next && data.comments.length > 0;
182-
if (data.comments.length > 0) {
183-
cursor = data.comments[data.comments.length - 1]?.id;
184-
}
187+
currentPage += 1;
185188
}
186189

187190
return {comments: all, meta: {pagination: {next: false}}};
@@ -190,6 +193,10 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}: {site
190193
const params = new URLSearchParams();
191194
params.set('limit', (limit ?? 5).toString());
192195

196+
if (page) {
197+
params.set('page', page.toString());
198+
}
199+
193200
if (afterReplyId) {
194201
params.set('filter', `id:>'${afterReplyId}'`);
195202
}

apps/comments-ui/test/unit/actions.test.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,78 @@ describe('Actions', function () {
8181
});
8282
});
8383

84+
describe('loadMoreReplies', function () {
85+
it('rebuilds the replies from the freshly loaded full set', async function () {
86+
// Only a partial set of replies is loaded inline...
87+
const comment = makeComment({
88+
id: 'comment-1',
89+
replies: [
90+
makeComment({id: 'reply-a', created_at: '2026-06-04T09:00:07.000Z', parent_id: 'comment-1'})
91+
]
92+
});
93+
const state = {comments: [comment]};
94+
// ...and the server returns the full set (ordered created_at ASC, id ASC).
95+
const fullReplies = [
96+
makeComment({id: 'reply-a', created_at: '2026-06-04T09:00:07.000Z', parent_id: 'comment-1'}),
97+
makeComment({id: 'reply-b', created_at: '2026-06-04T09:00:30.000Z', parent_id: 'comment-1'})
98+
];
99+
const api = {
100+
comments: {
101+
replies: vi.fn(() => Promise.resolve({comments: fullReplies, meta: {pagination: {next: null}}}))
102+
}
103+
};
104+
105+
const newState = await Actions.loadMoreReplies({state, api, data: {comment, limit: 'all'}});
106+
107+
expect(api.comments.replies).toHaveBeenCalledWith({commentId: 'comment-1', limit: 'all'});
108+
expect(newState.comments[0].replies.map(reply => reply.id)).toEqual(['reply-a', 'reply-b']);
109+
});
110+
111+
it('de-duplicates replies so none render twice (BER-3706)', async function () {
112+
const comment = makeComment({
113+
id: 'comment-1',
114+
replies: [
115+
makeComment({id: 'reply-a', created_at: '2026-06-04T09:00:07.000Z', parent_id: 'comment-1'})
116+
]
117+
});
118+
const state = {comments: [comment]};
119+
// Simulate a response that overlaps the same reply across pages.
120+
const replyA = makeComment({id: 'reply-a', created_at: '2026-06-04T09:00:07.000Z', parent_id: 'comment-1'});
121+
const replyB = makeComment({id: 'reply-b', created_at: '2026-06-04T09:00:30.000Z', parent_id: 'comment-1'});
122+
const api = {
123+
comments: {
124+
replies: vi.fn(() => Promise.resolve({comments: [replyA, replyB, replyB], meta: {pagination: {next: null}}}))
125+
}
126+
};
127+
128+
const newState = await Actions.loadMoreReplies({state, api, data: {comment, limit: 'all'}});
129+
130+
expect(newState.comments[0].replies.map(reply => reply.id)).toEqual(['reply-a', 'reply-b']);
131+
});
132+
133+
it('preserves a just-posted reply the server refetch has not caught up to yet', async function () {
134+
// addReply keeps a locally-posted reply when a cached GET omits it; opening
135+
// another reply form must not drop it when reloading replies.
136+
const serverReply = makeComment({id: 'reply-a', created_at: '2026-06-04T09:00:07.000Z', parent_id: 'comment-1'});
137+
const pendingReply = makeComment({id: 'reply-b', created_at: '2026-06-04T09:00:30.000Z', parent_id: 'comment-1'});
138+
const comment = makeComment({
139+
id: 'comment-1',
140+
replies: [serverReply, pendingReply]
141+
});
142+
const state = {comments: [comment]};
143+
// Stale response: missing the just-posted reply-b.
144+
const api = {
145+
comments: {
146+
replies: vi.fn(() => Promise.resolve({comments: [serverReply], meta: {pagination: {next: null}}}))
147+
}
148+
};
149+
150+
const newState = await Actions.loadMoreReplies({state, api, data: {comment, limit: 'all'}});
151+
152+
expect(newState.comments[0].replies.map(reply => reply.id)).toEqual(['reply-a', 'reply-b']);
153+
});
154+
});
155+
84156
describe('addReply', function () {
85157
it('adds the created reply when the replies refetch is stale', async function () {
86158
const newReply = makeComment({

apps/comments-ui/test/utils/mocked-api.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ export class MockedApi {
257257
};
258258
}
259259

260-
browseReplies({commentId, filter, limit = 5}: {commentId: string, filter?: string, limit?: number}) {
260+
browseReplies({commentId, filter, limit = 5, page = 1}: {commentId: string, filter?: string, limit?: number, page?: number}) {
261261
const comment = this.comments.find(c => c.id === commentId);
262262
if (!comment) {
263263
return {
@@ -288,18 +288,19 @@ export class MockedApi {
288288
});
289289
}
290290

291-
const limitedReplies = filteredReplies.slice(0, limit);
292-
const hasMore = filteredReplies.length > limit;
291+
const startIndex = (page - 1) * limit;
292+
const limitedReplies = filteredReplies.slice(startIndex, startIndex + limit);
293+
const hasMore = filteredReplies.length > startIndex + limit;
293294

294295
return {
295296
comments: limitedReplies,
296297
meta: {
297298
pagination: {
298-
page: 1,
299+
page,
299300
pages: Math.ceil(filteredReplies.length / limit),
300301
total: filteredReplies.length,
301302
limit,
302-
next: hasMore ? 2 : null,
303+
next: hasMore ? page + 1 : null,
303304
prev: null
304305
}
305306
}
@@ -528,13 +529,15 @@ export class MockedApi {
528529
const url = new URL(route.request().url());
529530

530531
const limit = parseInt(url.searchParams.get('limit') ?? '5');
532+
const page = parseInt(url.searchParams.get('page') ?? '1');
531533
const commentId = url.pathname.split('/').reverse()[2];
532534
const filter = url.searchParams.get('filter') ?? '';
533535

534536
await route.fulfill({
535537
status: 200,
536538
body: JSON.stringify(this.browseReplies({
537539
limit,
540+
page,
538541
filter,
539542
commentId
540543
}))

0 commit comments

Comments
 (0)