element-hq / element-hq/element-web

m.fully_read marker can be set to events outside main timeline (reactions, threaded replies), breaking "jump to first unread"

Open
#33,575 0 comments 0 reactions 0 assignees View on GitHub
A-Electron A-Read-Marker A-Threads O-Occasional S-Major T-Defect Z-ProductPolish
Dominant language
TypeScript
Stars
13.5k
Forks
2.8k
PR merge metrics
PR metrics pending

Description

### Steps to reproduce

Environment

- **Element Web**: 1.12.12 (also reproducible on 1.12.18 Desktop)
- **Synapse**: 1.152.1
- **Postgres**: 16
- **Single homeserver, no federation, ~50 users**

### Summary

`m.fully_read` markers in `room_account_data` end up pointing to events that are not in the main room timeline — specifically `m.reaction` events, threaded replies (`m.relates_to.rel_type = m.thread`), and annotations (`rel_type = m.annotation`). When user clicks "jump to first unread", Element fails with:

```
Error: No timeline given to initFields
at TimelineWindow.initFields (timeline-window.ts:101)
at TimelineWindow.load (timeline-window.ts:128)
...
loadTimeline @ TimelinePanel.tsx:1522
```

And a modal: **"Failed to load timeline position. Tried to load a specific point in this room's timeline, but was unable to find it."**

### Root cause

`TimelinePanel` is constructed with `props.timelineSet = mainTimelineSet`. When `loadTimeline(eventId)` is called with an `eventId` that's a threaded reply, reaction, or annotation, `TimelineWindow.load()` tries three lookups (`matrix-js-sdk/src/timeline-window.ts:126-130`):

```typescript
initFields(this.timelineSet.getTimelineForEvent(initialEventId)); // L126
return this.client.getEventTimeline(this.timelineSet, initialEventId).then(initFields); // L128
initFields(this.timelineSet.getLiveTimeline()); // L130
```

All three return `null` when the eventId is not visible in the main timelineSet:
- Threaded replies live in their own thread timelineSet
- Reactions and edits don't have their own timeline; they aggregate to parent

`initFields` then throws `"No timeline given to initFields"`.

### Steps to reproduce

I don't have a clean minimal UI repro yet — what I have is:

1. Use Element Web 1.12.x (or Desktop, any 1.12 we've seen, including latest 1.12.18) against Synapse 1.152.
2. Be active in a room that has threads and reactions.
3. Let Element run for a while (hours to days of normal usage).
4. Check the server-side state via SQL on Synapse's Postgres:
```sql
SELECT rad.user_id, rad.room_id, rad.content
FROM room_account_data rad
JOIN events e ON e.event_id = (rad.content::json->>'event_id')
LEFT JOIN event_json ej ON ej.event_id = (rad.content::json->>'event_id')
WHERE rad.account_data_type = 'm.fully_read'
AND rad.user_id = '@myself:my.server'
AND (
e.type = 'm.reaction'
OR ej.json::json->'content'->'m.relates_to'->>'rel_type' IN ('m.thread', 'm.annotation')
);
```
5. If the query returns any rows, open one of those rooms in Element and click "↑ jump to first unread".

Suspect path that triggers it (not confirmed): when a thread is opened and scrolled, or when a reaction arrives as the most recent event in a room, Element treats "latest processed event" as the read-up-to and posts it via `POST /_matrix/client/v3/rooms/{room}/read_markers`.

### Outcome

**Expected:** "↑ jump to first unread" navigates to the position of the read marker. Either Element shouldn't set the marker on a non-main-timeline event in the first place, or it should handle the navigation gracefully (e.g. fall back to thread root or last main-timeline event).

**Actual:** Element shows modal "Failed to load timeline position. Tried to load a specific point in this room's timeline, but was unable to find it." DevTools console has:

```
Error loading timeline panel at !ROOM:server/$EVENT
Error: No timeline given to initFields
at n (timeline-window.ts:101:23)
loadTimeline @ TimelinePanel.tsx:1522
(loaded from rageshake.ts:73)
```

### Scale we observe

On our deployment we accumulated **~10k such markers over ~15 months on Synapse 1.101** and continue to see **~50-100 new per day on Synapse 1.152.1** even after upgrade. Affected across multiple Element versions: 1.12.18 Desktop, 1.12.12 Web, 1.12.7-11, 1.11.81, Element Classic 1.11.37 and 1.6.x.

### Spec angle

Per [[Matrix spec v1.18 — Fully read markers](https://spec.matrix.org/v1.18/client-server-api/#fully-read-markers)](https://spec.matrix.org/v1.18/client-server-api/#fully-read-markers), the only constraint on `m.fully_read.content.event_id` is "the event MUST belong to the room". There's no type constraint, so technically these markers are valid.

But it's a clear implementation gap: Element sets a marker it cannot then navigate to. Probably one of:

1. Element should not set `m.fully_read` to non-main-timeline events (reactions, threaded replies).
2. Or Element should be able to handle markers on those events — e.g. for threaded replies, navigate to the thread root or just skip.

For comparison: for `m.read` receipts with `thread_id="main"`, the spec [[explicitly returns 400](https://spec.matrix.org/v1.18/client-server-api/#post_matrixclientv3roomsroomidreceiptreceipttypeeventid)](https://spec.matrix.org/v1.18/client-server-api/#post_matrixclientv3roomsroomidreceiptreceipttypeeventid) if `event_id` isn't related to `thread_id`. The same logic seems sensible for `m.fully_read`.

### Workaround we're using

Direct SQL update on Synapse DB:

```sql
WITH bad AS (
SELECT rad.user_id, rad.room_id
FROM room_account_data rad
JOIN events e ON e.event_id = (rad.content::json->>'event_id')
LEFT JOIN event_json ej ON ej.event_id = (rad.content::json->>'event_id')
WHERE rad.account_data_type = 'm.fully_read'
AND (e.type = 'm.reaction'
OR ej.json::json->'content'->'m.relates_to'->>'rel_type' IN ('m.thread','m.annotation'))
),
targets AS (
SELECT b.user_id, b.room_id, ln.event_id AS new_eid
FROM bad b
JOIN LATERAL (
SELECT e2.event_id FROM events e2
LEFT JOIN event_json ej2 ON ej2.event_id = e2.event_id
WHERE e2.room_id = b.room_id
AND e2.type IN ('m.room.message','m.room.encrypted')
AND e2.rejection_reason IS NULL AND NOT e2.outlier
AND (ej2.json::json->'content'->'m.relates_to'->>'rel_type' IS NULL
OR ej2.json::json->'content'->'m.relates_to'->>'rel_type' NOT IN ('m.thread','m.annotation'))
ORDER BY e2.stream_ordering DESC LIMIT 1
) ln ON true
)
UPDATE room_account_data rad
SET content = json_build_object('event_id', t.new_eid)::text,
stream_id = nextval('account_data_sequence')
FROM targets t
WHERE rad.user_id = t.user_id
AND rad.room_id = t.room_id
AND rad.account_data_type = 'm.fully_read';
```

This moves the marker to the last "normal" main-timeline message in the room. After this + Synapse restart + client cache clear, "jump to first unread" works again.

### Related observation: stale local marker

After the server-side fix, Element keeps showing the same error until users do **Settings → Help & About → Clear Cache & Reload**. The local IndexedDB retains the old marker even after `/sync` delivers updated `m.fully_read` in account_data. Looks like Element trusts local cache over server delta in some path.

### What would help

- Confirm whether Element is expected to set `m.fully_read` to non-message events
- If not — fix in client to either (a) avoid those events when computing the marker, or (b) handle the "marker on threaded/reaction" gracefully in `TimelinePanel.loadTimeline` (e.g. fall back to thread root, or to last main-timeline event)
- Update local IndexedDB sync logic to trust server `m.fully_read` updates over stale local

Happy to provide more diagnostic data, run targeted tests, or PR if maintainers can point at the right code path. Thanks!

### Operating system

macOS, Linux, Windows,etc

### Application version

1.12.12 (also reproducible on 1.12.18 Desktop)

### How did you install the app?

_No response_

### Homeserver

Synapse: 1.152.1

### Will you send logs?

Yes

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.