Marking a thread read redrew the message pane: the mail vanished and came back, white → dark → white on an HTML message that brings its own colours, half a second after the reader started reading it. Worst with auto-mark set to immediately, where it happens the moment the thread opens.
The pane was not re-mounting
The body was being thrown away and built again, and the reason is one dependency.
HtmlBody writes the message into a shadow root in an effect, and that effect had the click handler in its dependency list. The handler is a useCallback over onShowImages — which the parent passed as an arrow created inline (MessageView.tsx:272), so it was a new function on every render. Therefore the effect ran on every render, and every render replaced the rendered message with an identical one.
Marking as read is exactly such a render: the store hands back a new email object and the thread re-renders.
The fix
The listener now lives in its own effect, attached to the shadow root rather than to its contents — which survives the rewriting anyway. A handler that changes identity now costs a listener swap and nothing else.
onShowImages is stable now too, but the split is the fix: it's what makes the body immune to the next handler that changes.
This also stops the quoted-text toggle collapsing. setQuoteOpen(false) lives in the same effect and had been resetting on every render, so expanding a quote and waiting for the timer put it away again.
Measured, not watched
A flicker is exactly the thing an eye will agree with you about. Holding a node from inside the shadow root across the transition, same three-message thread, markReadDelay: 0:
childList mutations on the root
the held body node
before
21
detached and replaced
after
0
same node, still attached
Clicking a blocked image still reveals remote images, which is what the moved listener is for.
333 web and 77 server tests pass. npm run typecheck clean.
Closes #100.
Marking a thread read redrew the message pane: the mail vanished and came back, white → dark → white on an HTML message that brings its own colours, half a second after the reader started reading it. Worst with auto-mark set to *immediately*, where it happens the moment the thread opens.
### The pane was not re-mounting
The **body** was being thrown away and built again, and the reason is one dependency.
`HtmlBody` writes the message into a shadow root in an effect, and that effect had the click handler in its dependency list. The handler is a `useCallback` over `onShowImages` — which the parent passed as an arrow created inline (`MessageView.tsx:272`), so it was a new function on **every render**. Therefore the effect ran on every render, and every render replaced the rendered message with an identical one.
Marking as read is exactly such a render: the store hands back a new email object and the thread re-renders.
### The fix
The listener now lives in its own effect, attached to the shadow root rather than to its contents — which survives the rewriting anyway. A handler that changes identity now costs a listener swap and nothing else.
`onShowImages` is stable now too, but **the split is the fix**: it's what makes the body immune to the next handler that changes.
This also stops the quoted-text toggle collapsing. `setQuoteOpen(false)` lives in the same effect and had been resetting on every render, so expanding a quote and waiting for the timer put it away again.
### Measured, not watched
A flicker is exactly the thing an eye will agree with you about. Holding a node from inside the shadow root across the transition, same three-message thread, `markReadDelay: 0`:
| | childList mutations on the root | the held body node |
| --- | --- | --- |
| before | **21** | detached and replaced |
| after | **0** | same node, still attached |
Clicking a blocked image still reveals remote images, which is what the moved listener is for.
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@453a62115be7
<sub>Rebuilt from: GH Archive, 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.
Closes #100.
Marking a thread read redrew the message pane: the mail vanished and came back, white → dark → white on an HTML message that brings its own colours, half a second after the reader started reading it. Worst with auto-mark set to immediately, where it happens the moment the thread opens.
The pane was not re-mounting
The body was being thrown away and built again, and the reason is one dependency.
HtmlBodywrites the message into a shadow root in an effect, and that effect had the click handler in its dependency list. The handler is auseCallbackoveronShowImages— which the parent passed as an arrow created inline (MessageView.tsx:272), so it was a new function on every render. Therefore the effect ran on every render, and every render replaced the rendered message with an identical one.Marking as read is exactly such a render: the store hands back a new email object and the thread re-renders.
The fix
The listener now lives in its own effect, attached to the shadow root rather than to its contents — which survives the rewriting anyway. A handler that changes identity now costs a listener swap and nothing else.
onShowImagesis stable now too, but the split is the fix: it's what makes the body immune to the next handler that changes.This also stops the quoted-text toggle collapsing.
setQuoteOpen(false)lives in the same effect and had been resetting on every render, so expanding a quote and waiting for the timer put it away again.Measured, not watched
A flicker is exactly the thing an eye will agree with you about. Holding a node from inside the shadow root across the transition, same three-message thread,
markReadDelay: 0:Clicking a blocked image still reveals remote images, which is what the moved listener is for.
333 web and 77 server tests pass.
npm run typecheckclean.🤖 Generated with Claude Code
Merged 2026-08-27 as coffey-labs/ihasmail@453a62115b
Rebuilt from: GH Archive, git history, session transcript.