Stop rebuilding the message body when it is marked read #104

Closed
opened 2026-08-27 19:18:00 +00:00 by jcoffey-dev · 0 comments
Owner

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

Merged 2026-08-27 as coffey-labs/ihasmail@453a62115b

Rebuilt from: GH Archive, git history, session transcript.

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.