The rest of #100. The reading pane empties and refills when a thread is marked read — on an HTML message, a flash to the app's own background and out again. That's what remained after #104 stopped the message view rebuilding its body.
Cause
applyChanges dropped fullIds for every email the server reported as updated, so the next read would fetch it again:
// Drop cached versions of updated emails so they're refetched lazily.
for(constidofupdated){if(next[id]&&nextFull[id])deletenextFull[id];}
But the reading pane renders only emails it holds in full (ThreadView.tsx, filter(e => Boolean(e && fullIds[e.id]))). Dropping one takes the message out of the open thread until the refetch at the end of the same function puts it back — and marking as read causes exactly that, because the server echoes our own change back as an update. The gap is a network round trip, which is why it's plainly visible against a real server and invisible locally.
Why keeping it is safe
RFC 8621 makes every property of an Email immutable except keywords and mailboxIds — the id is derived from the content, so a body cannot change beneath one. Both are in LIST_PROPS, which the refresh immediately below already merges over the cached copy. The eviction only ever cost the message its place in the thread.
On the evidence, plainly
I got this wrong once already by trusting a reproduction that didn't exist, so: this is reasoned from the code and matched against the reported symptom — "the pane empties and comes back", which is precisely what removing an email from the thread and refetching it looks like.
It is not backed by a local reproduction. The mock has never run this path: Email/set announced nothing and Email/changes always answered empty, so every check ever made here was against a server that never reported the change being made. #105 fixes that and should land alongside this, so the next person can reproduce it.
333 web and 77 server tests pass. npm run typecheck clean.
The rest of #100. The reading pane **empties and refills** when a thread is marked read — on an HTML message, a flash to the app's own background and out again. That's what remained after #104 stopped the message view rebuilding its body.
### Cause
`applyChanges` dropped `fullIds` for every email the server reported as updated, so the next read would fetch it again:
```js
// Drop cached versions of updated emails so they're refetched lazily.
for (const id of updated) {
if (next[id] && nextFull[id]) delete nextFull[id];
}
```
But the reading pane renders only emails it holds in full (`ThreadView.tsx`, `filter(e => Boolean(e && fullIds[e.id]))`). Dropping one takes the message out of the open thread until the refetch at the end of the same function puts it back — and marking as read causes exactly that, because the server echoes our own change back as an update. The gap is a network round trip, which is why it's plainly visible against a real server and invisible locally.
### Why keeping it is safe
RFC 8621 makes every property of an `Email` immutable except `keywords` and `mailboxIds` — the id is derived from the content, so a body cannot change beneath one. Both are in `LIST_PROPS`, which the refresh immediately below already merges over the cached copy. The eviction only ever cost the message its place in the thread.
### On the evidence, plainly
I got this wrong once already by trusting a reproduction that didn't exist, so: **this is reasoned from the code and matched against the reported symptom** — "the pane empties and comes back", which is precisely what removing an email from the thread and refetching it looks like.
It is **not** backed by a local reproduction. The mock has never run this path: `Email/set` announced nothing and `Email/changes` always answered empty, so every check ever made here was against a server that never reported the change being made. #105 fixes that and should land alongside this, so the next person can reproduce it.
333 web and 77 server tests pass. `npm run typecheck` clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
**Merged** 2026-08-27 as coffey-labs/ihasmail@31239ed9be4c
<sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The rest of #100. The reading pane empties and refills when a thread is marked read — on an HTML message, a flash to the app's own background and out again. That's what remained after #104 stopped the message view rebuilding its body.
Cause
applyChangesdroppedfullIdsfor every email the server reported as updated, so the next read would fetch it again:But the reading pane renders only emails it holds in full (
ThreadView.tsx,filter(e => Boolean(e && fullIds[e.id]))). Dropping one takes the message out of the open thread until the refetch at the end of the same function puts it back — and marking as read causes exactly that, because the server echoes our own change back as an update. The gap is a network round trip, which is why it's plainly visible against a real server and invisible locally.Why keeping it is safe
RFC 8621 makes every property of an
Emailimmutable exceptkeywordsandmailboxIds— the id is derived from the content, so a body cannot change beneath one. Both are inLIST_PROPS, which the refresh immediately below already merges over the cached copy. The eviction only ever cost the message its place in the thread.On the evidence, plainly
I got this wrong once already by trusting a reproduction that didn't exist, so: this is reasoned from the code and matched against the reported symptom — "the pane empties and comes back", which is precisely what removing an email from the thread and refetching it looks like.
It is not backed by a local reproduction. The mock has never run this path:
Email/setannounced nothing andEmail/changesalways answered empty, so every check ever made here was against a server that never reported the change being made. #105 fixes that and should land alongside this, so the next person can reproduce it.333 web and 77 server tests pass.
npm run typecheckclean.🤖 Generated with Claude Code
Merged 2026-08-27 as coffey-labs/ihasmail@31239ed9be
Rebuilt from: git history, session transcript.