Skip to content

[2.x] fix: Stop a realtime update duplicating a discussion in the list - #4993

Merged
imorland merged 2 commits into
2.xfrom
im/discussion-list-dedup-by-id
Aug 26, 2026
Merged

imorland merged 2 commits into
2.xfrom
im/discussion-list-dedup-by-id

Conversation

@imorland

Copy link
Copy Markdown
Member

Changes proposed in this pull request:

DiscussionListState adds and removes discussions by matching on the model instance (indexOf). Realtime hands the list a discussion resolved through app.store.pushPayload, and the store returns a fresh instance whenever it no longer holds the one already on screen — a discussion that has scrolled out of the loaded pages, for instance. The reference match then fails to find the copy in the list, so addDiscussion adds the discussion a second time, and it shows twice until the page is refreshed. (Reported against rc.7: "left the tab idle, came back to a duplicate discussion; refreshing fixes it.")

  • deleteDiscussion now matches by discussion.id() rather than object reference, so a fresh instance of a discussion already listed replaces it instead of duplicating it.
  • It also fixes this.extraDiscussions.splice(index) — missing its length argument, it removed the matched item and everything after it — to splice(index, 1).
  • A null id is guarded, so an unsaved discussion (should one ever reach the list) can't match another by null === null.

A note on forums without realtime: addDiscussion has no caller in core or any bundled extension — only realtime adds discussions to the list this way — so a non-realtime forum never hits the duplicate path at all. Its only route through this code is discussion deletion (PostControls, DiscussionControls), which passes the canonical list instance; there the id match and the old reference match are equivalent, so behaviour is unchanged.

Reviewers should focus on:

  • That matching by id is safe here — the list only ever holds saved discussions (pages come from the API; extraDiscussions only ever receives store-resolved discussions), so ids are present and unique.
  • That the splice(index) → splice(index, 1) change is correct — the old form was removing a tail of the pending list.

Necessity

  • Has the problem that is being solved here been clearly explained? — a realtime update can show a discussion twice until refresh.
  • If applicable, have various options for solving this problem been considered? — matching by id reuses the identity the store already keys on; tracking instances some other way would be heavier and no more correct.
  • For core PRs, does this need to be in core, or could it be in an extension? — the list state lives in core; realtime and other extensions build on it.
  • Are we willing to maintain this for years / potentially forever?

Confirmed

  • Frontend changes: tested on a local Flarum installation. — reproduced the duplicate and its fix in a unit test (a fresh store instance of a listed discussion); the realtime path drives exactly this.
  • Frontend changes: tests are green.
  • Frontend changes: tests have been added. — covers, with realtime, re-adding the same and a fresh instance (no duplicate) and that the re-add moves to the top; and, without realtime, that removal drops the discussion and doesn't take the rest of the list with it.
  • Backend changes: tests are green — no backend changes.
  • Backend changes: tests have been added, or are not appropriate here — n/a.
  • Where applicable, changes are suitable for all supported database drivers (MySQL, MariaDB, PostgreSQL, SQLite). — no database involvement.
  • The description above is written by me and describes what this pull request actually does.

Required changes:

  • Related documentation PR: (Remove if irrelevant)

The discussion list adds and removes discussions by matching on the model
instance. Realtime hands it a discussion resolved through the store, and
the store returns a fresh instance whenever it no longer holds the one
already on screen — a discussion that has scrolled out, say. The
reference match then misses the copy in the list, so the discussion is
added a second time and shows twice until the page is refreshed.

Match by discussion id instead, so a fresh instance of a discussion
already listed replaces it rather than duplicating it. Also fix
removeDiscussion dropping every item after the one it removes from the
pending list — splice(index) with no length — which could clear part of
the list.

A forum without realtime never adds to the list this way, so its only
path here is deletion, where the id match and the old reference match are
equivalent.
@imorland
imorland requested a review from a team as a code owner August 26, 2026 15:34
@imorland imorland changed the title Stop a realtime update duplicating a discussion in the list [2.x] fix: Stop a realtime update duplicating a discussion in the list Aug 26, 2026
@imorland imorland added this to the 2.0.0-rc.8 milestone Aug 26, 2026
@imorland
imorland merged commit ed9145a into 2.x Aug 26, 2026
2 checks passed
@imorland
imorland deleted the im/discussion-list-dedup-by-id branch August 26, 2026 16:03
imorland pushed a commit that referenced this pull request Aug 31, 2026
…#5003)

Returning to a tab that had been idle long enough for the websocket to die
shows a discussion listed twice, until the reader refreshes.

`refresh()` empties `extraDiscussions`, because it routes through `clear()`.
`revalidate()` (#4889) deliberately clears nothing before it asks the API —
that is the point of it, the list stays on screen — so it rebuilds `pages` and
leaves `extraDiscussions` alone. The first page it gets back contains exactly
the discussions realtime had put there, since a new post is what moved them to
the top, and the list then renders them from both halves at once.

`addDiscussion` has no caller in core; only realtime adds discussions this way,
so only a forum with realtime enabled can reach the duplicate at all. Nothing
on the path calls `deleteDiscussion`, which is why matching there by id rather
than by reference (#4993) does not close it.

- Reconcile after the revalidation lands, dropping from `extraDiscussions`
  only the ids the new pages actually contain. Not a blanket clear: a
  revalidation that failed resolves rather than rejecting and leaves `pages`
  untouched, and clearing then would take realtime's additions off a list that
  was never reloaded.
- Stop `getAllItems()` counting `extraDiscussions` twice. It concatenated them
  onto `super.getAllItems()`, which flattens the `getPages()` this class had
  already overridden to prepend them. Harmless where it is read today
  (`isEmpty()`), wrong for anything that counts.

Co-authored-by: Claude Opus 5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant